Skip to content

Commit 90b9978

Browse files
feat: Add a separate function for service startup validation (#1038)
1 parent be51520 commit 90b9978

File tree

1 file changed

+158
-118
lines changed

1 file changed

+158
-118
lines changed

appium/webdriver/appium_service.py

Lines changed: 158 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@
1717
import subprocess as sp
1818
import sys
1919
import time
20-
from typing import Any, List, Optional, Set
20+
from typing import Any, Callable, List, Optional, Set
2121

2222
from selenium.webdriver.remote.remote_connection import urllib3
2323

2424
DEFAULT_HOST = '127.0.0.1'
2525
DEFAULT_PORT = 4723
2626
STARTUP_TIMEOUT_MS = 60000
27+
STATE_CHECK_INTERVAL_MS = 500
2728
MAIN_SCRIPT_PATH = 'appium/build/lib/main.js'
2829
STATUS_URL = '/status'
2930
DEFAULT_BASE_PATH = '/'
@@ -37,111 +38,11 @@ class AppiumStartupError(RuntimeError):
3738
pass
3839

3940

40-
def find_executable(executable: str) -> Optional[str]:
41-
path = os.environ['PATH']
42-
paths = path.split(os.pathsep)
43-
_, ext = os.path.splitext(executable)
44-
if sys.platform == 'win32' and not ext:
45-
executable = executable + '.exe'
46-
47-
if os.path.isfile(executable):
48-
return executable
49-
50-
for p in paths:
51-
full_path = os.path.join(p, executable)
52-
if os.path.isfile(full_path):
53-
return full_path
54-
55-
return None
56-
57-
58-
def get_node() -> str:
59-
result = find_executable('node')
60-
if result is None:
61-
raise AppiumServiceError(
62-
'NodeJS main executable cannot be found. Make sure it is installed and present in PATH'
63-
)
64-
return result
65-
66-
67-
def get_npm() -> str:
68-
result = find_executable('npm.cmd' if sys.platform == 'win32' else 'npm')
69-
if result is None:
70-
raise AppiumServiceError(
71-
'Node Package Manager executable cannot be found. Make sure it is installed and present in PATH'
72-
)
73-
return result
74-
75-
76-
def get_main_script(node: Optional[str], npm: Optional[str]) -> str:
77-
result: Optional[str] = None
78-
npm_path = npm or get_npm()
79-
for args in [['root', '-g'], ['root']]:
80-
try:
81-
modules_root = sp.check_output([npm_path] + args).strip().decode('utf-8')
82-
if os.path.exists(os.path.join(modules_root, MAIN_SCRIPT_PATH)):
83-
result = os.path.join(modules_root, MAIN_SCRIPT_PATH)
84-
break
85-
except sp.CalledProcessError:
86-
continue
87-
if result is None:
88-
node_path = node or get_node()
89-
try:
90-
result = (
91-
sp.check_output([node_path, '-e', f'console.log(require.resolve("{MAIN_SCRIPT_PATH}"))'])
92-
.decode('utf-8')
93-
.strip()
94-
)
95-
except sp.CalledProcessError as e:
96-
raise AppiumServiceError(e.output) from e
97-
return result
98-
99-
100-
def parse_arg_value(args: List[str], arg_names: Set[str], default: str) -> str:
101-
for idx, arg in enumerate(args):
102-
if arg in arg_names and idx < len(args) - 1:
103-
return args[idx + 1]
104-
return default
105-
106-
107-
def parse_port(args: List[str]) -> int:
108-
return int(parse_arg_value(args, {'--port', '-p'}, str(DEFAULT_PORT)))
109-
110-
111-
def parse_base_path(args: List[str]) -> str:
112-
return parse_arg_value(args, {'--base-path', '-pa'}, DEFAULT_BASE_PATH)
113-
114-
115-
def parse_host(args: List[str]) -> str:
116-
return parse_arg_value(args, {'--address', '-a'}, DEFAULT_HOST)
117-
118-
119-
def make_status_url(args: List[str]) -> str:
120-
base_path = parse_base_path(args)
121-
return STATUS_URL if base_path == DEFAULT_BASE_PATH else f'{re.sub(r"/+$", "", base_path)}{STATUS_URL}'
122-
123-
12441
class AppiumService:
12542
def __init__(self) -> None:
12643
self._process: Optional[sp.Popen] = None
12744
self._cmd: Optional[List[str]] = None
12845

129-
def _poll_status(self, host: str, port: int, path: str, timeout_ms: int) -> bool:
130-
time_started_sec = time.time()
131-
conn = urllib3.PoolManager(timeout=1.0)
132-
while time.time() < time_started_sec + timeout_ms / 1000.0:
133-
if not self.is_running:
134-
raise AppiumStartupError()
135-
# noinspection PyUnresolvedReferences
136-
try:
137-
resp = conn.request('HEAD', f'http://{host}:{port}{path}')
138-
if resp.status < 400:
139-
return True
140-
except urllib3.exceptions.HTTPError:
141-
pass
142-
time.sleep(1.0)
143-
return False
144-
14546
def start(self, **kwargs: Any) -> sp.Popen:
14647
"""Starts Appium service with given arguments.
14748
@@ -173,8 +74,7 @@ def start(self, **kwargs: Any) -> sp.Popen:
17374
https://appium.io/docs/en/writing-running-appium/server-args/ for more details
17475
about possible arguments and their values.
17576
176-
Returns:
177-
You can use Popen.communicate interface or stderr/stdout properties
77+
:return: You can use Popen.communicate interface or stderr/stdout properties
17878
of the instance (stdout/stderr must not be set to None in such case) in order to retrieve the actual process
17979
output.
18080
"""
@@ -201,11 +101,15 @@ def start(self, **kwargs: Any) -> sp.Popen:
201101
f'method arguments.'
202102
)
203103
if timeout_ms > 0:
204-
status_url_path = make_status_url(args)
104+
server_url = _make_server_url(args)
205105
try:
206-
if not self._poll_status(parse_host(args), parse_port(args), status_url_path, timeout_ms):
106+
if not is_service_listening(
107+
server_url,
108+
timeout=timeout_ms / 1000,
109+
custom_validator=self._assert_is_running,
110+
):
207111
error_msg = (
208-
f'Appium server has started but is not listening on {status_url_path} '
112+
f'Appium server has started but is not listening on {server_url} '
209113
f'within {timeout_ms}ms timeout. Make sure proper values have been provided '
210114
f'to --base-path, --address and --port process arguments.'
211115
)
@@ -215,38 +119,45 @@ def start(self, **kwargs: Any) -> sp.Popen:
215119
error_msg = startup_failure_msg
216120
if error_msg is not None:
217121
if stderr == sp.PIPE and self._process.stderr is not None:
122+
# noinspection PyUnresolvedReferences
218123
err_output = self._process.stderr.read()
219124
if err_output:
220125
error_msg += f'\nOriginal error: {str(err_output)}'
221126
self.stop()
222127
raise AppiumServiceError(error_msg)
223128
return self._process
224129

225-
def stop(self) -> bool:
130+
def stop(self, timeout: float = 5.5) -> bool:
226131
"""Stops Appium service if it is running.
227132
228133
The call will be ignored if the service is not running
229134
or has been already stopped.
230135
231-
Returns:
232-
`True` if the service was running before being stopped
136+
:param timeout: The maximum time in float seconds to wait
137+
for the server process to terminate
138+
:return: `True` if the service was running before being stopped
233139
"""
234-
is_terminated = False
140+
was_running = False
235141
if self.is_running:
236142
assert self._process
143+
was_running = True
237144
self._process.terminate()
238-
self._process.communicate(timeout=5)
239-
is_terminated = True
145+
try:
146+
self._process.communicate(timeout=timeout)
147+
except sp.SubprocessError:
148+
if sys.platform == 'win32':
149+
sp.call(['taskkill', '/f', '/pid', str(self._process.pid)])
150+
else:
151+
self._process.kill()
240152
self._process = None
241153
self._cmd = None
242-
return is_terminated
154+
return was_running
243155

244156
@property
245157
def is_running(self) -> bool:
246158
"""Check if the service is running.
247159
248-
Returns:
249-
bool: `True` if the service is running
160+
:return: `True` if the service is running
250161
"""
251162
return self._process is not None and self._cmd is not None and self._process.poll() is None
252163

@@ -258,18 +169,147 @@ def is_listening(self) -> bool:
258169
The default host/port/base path values can be customized by providing
259170
--address/--port/--base-path command line arguments while starting the service.
260171
261-
Returns:
262-
bool: `True` if the service is running and listening on the given/default host/port
172+
:return: `True` if the service is running and listening on the given/default host/port
263173
"""
264174
if not self.is_running:
265175
return False
266176

267177
assert self._cmd
268178
try:
269-
return self._poll_status(parse_host(self._cmd), parse_port(self._cmd), make_status_url(self._cmd), 1000)
179+
return is_service_listening(
180+
_make_server_url(self._cmd),
181+
timeout=STATE_CHECK_INTERVAL_MS,
182+
custom_validator=self._assert_is_running,
183+
)
270184
except AppiumStartupError:
271185
return False
272186

187+
def _assert_is_running(self) -> None:
188+
if not self.is_running:
189+
raise AppiumStartupError()
190+
191+
192+
def is_service_listening(url: str, timeout: float = 5, custom_validator: Optional[Callable[[], None]] = None) -> bool:
193+
"""
194+
Check if the service is running
195+
196+
:param url: Full server url
197+
:param timeout: Timeout in float seconds
198+
:param custom_validator: Custom callable method to be executed upon each validation loop before the timeout happens
199+
:return: True if Appium server is running before the timeout
200+
"""
201+
time_started_sec = time.perf_counter()
202+
conn = urllib3.PoolManager(timeout=1.0)
203+
while time.perf_counter() < time_started_sec + timeout:
204+
if custom_validator is not None:
205+
custom_validator()
206+
# noinspection PyUnresolvedReferences
207+
try:
208+
resp = conn.request('HEAD', url)
209+
if resp.status < 400:
210+
return True
211+
except urllib3.exceptions.HTTPError:
212+
pass
213+
time.sleep(STATE_CHECK_INTERVAL_MS / 1000.0)
214+
return False
215+
216+
217+
def find_executable(executable: str) -> Optional[str]:
218+
path = os.environ['PATH']
219+
paths = path.split(os.pathsep)
220+
_, ext = os.path.splitext(executable)
221+
if sys.platform == 'win32' and not ext:
222+
executable = executable + '.exe'
223+
224+
if os.path.isfile(executable):
225+
return executable
226+
227+
for p in paths:
228+
full_path = os.path.join(p, executable)
229+
if os.path.isfile(full_path):
230+
return full_path
231+
232+
return None
233+
234+
235+
def get_node() -> str:
236+
result = find_executable('node')
237+
if result is None:
238+
raise AppiumServiceError(
239+
'NodeJS main executable cannot be found. Make sure it is installed and present in PATH'
240+
)
241+
return result
242+
243+
244+
def get_npm() -> str:
245+
result = find_executable('npm.cmd' if sys.platform == 'win32' else 'npm')
246+
if result is None:
247+
raise AppiumServiceError(
248+
'Node Package Manager executable cannot be found. Make sure it is installed and present in PATH'
249+
)
250+
return result
251+
252+
253+
def get_main_script(node: Optional[str], npm: Optional[str]) -> str:
254+
result: Optional[str] = None
255+
npm_path = npm or get_npm()
256+
for args in [['root', '-g'], ['root']]:
257+
try:
258+
modules_root = sp.check_output([npm_path] + args).strip().decode('utf-8')
259+
full_path = os.path.join(modules_root, *MAIN_SCRIPT_PATH.split('/'))
260+
if os.path.exists(full_path):
261+
result = full_path
262+
break
263+
except sp.CalledProcessError:
264+
continue
265+
if result is None:
266+
node_path = node or get_node()
267+
try:
268+
result = (
269+
sp.check_output([node_path, '-e', f'console.log(require.resolve("{MAIN_SCRIPT_PATH}"))'])
270+
.decode('utf-8')
271+
.strip()
272+
)
273+
except sp.CalledProcessError as e:
274+
raise AppiumServiceError(e.output) from e
275+
return result
276+
277+
278+
def _parse_arg_value(args: List[str], arg_names: Set[str], default: str) -> str:
279+
for idx, arg in enumerate(args):
280+
if arg in arg_names and idx < len(args) - 1:
281+
return args[idx + 1]
282+
return default
283+
284+
285+
def _parse_port(args: List[str]) -> int:
286+
return int(_parse_arg_value(args, {'--port', '-p'}, str(DEFAULT_PORT)))
287+
288+
289+
def _parse_base_path(args: List[str]) -> str:
290+
return _parse_arg_value(args, {'--base-path', '-pa'}, DEFAULT_BASE_PATH)
291+
292+
293+
def _parse_host(args: List[str]) -> str:
294+
return _parse_arg_value(args, {'--address', '-a'}, DEFAULT_HOST)
295+
296+
297+
def _parse_protocol(args: List[str]) -> str:
298+
return (
299+
'https'
300+
if _parse_arg_value(args, {'--ssl-cert-path'}, '') and _parse_arg_value(args, {'--ssl-key-path'}, '')
301+
else 'http'
302+
)
303+
304+
305+
def _make_status_path(args: List[str]) -> str:
306+
base_path = _parse_base_path(args)
307+
return STATUS_URL if base_path == DEFAULT_BASE_PATH else f'{re.sub(r"/+$", "", base_path)}{STATUS_URL}'
308+
309+
310+
def _make_server_url(args: List[str]) -> str:
311+
return f'{_parse_protocol(args)}://{_parse_host(args)}:{_parse_port(args)}{_make_status_path(args)}'
312+
273313

274314
if __name__ == '__main__':
275315
assert find_executable('node') is not None

0 commit comments

Comments
 (0)