1717import subprocess as sp
1818import sys
1919import time
20- from typing import Any , List , Optional , Set
20+ from typing import Any , Callable , List , Optional , Set
2121
2222from selenium .webdriver .remote .remote_connection import urllib3
2323
2424DEFAULT_HOST = '127.0.0.1'
2525DEFAULT_PORT = 4723
2626STARTUP_TIMEOUT_MS = 60000
27+ STATE_CHECK_INTERVAL_MS = 500
2728MAIN_SCRIPT_PATH = 'appium/build/lib/main.js'
2829STATUS_URL = '/status'
2930DEFAULT_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-
12441class 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'\n Original 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
274314if __name__ == '__main__' :
275315 assert find_executable ('node' ) is not None
0 commit comments