Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
SeleniumHQ
GitHub Repository: SeleniumHQ/Selenium
Path: blob/trunk/py/selenium/webdriver/common/service.py
1864 views
1
# Licensed to the Software Freedom Conservancy (SFC) under one
2
# or more contributor license agreements. See the NOTICE file
3
# distributed with this work for additional information
4
# regarding copyright ownership. The SFC licenses this file
5
# to you under the Apache License, Version 2.0 (the
6
# "License"); you may not use this file except in compliance
7
# with the License. You may obtain a copy of the License at
8
#
9
# http://www.apache.org/licenses/LICENSE-2.0
10
#
11
# Unless required by applicable law or agreed to in writing,
12
# software distributed under the License is distributed on an
13
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
# KIND, either express or implied. See the License for the
15
# specific language governing permissions and limitations
16
# under the License.
17
18
import errno
19
import logging
20
import os
21
import subprocess
22
import sys
23
from abc import ABC, abstractmethod
24
from collections.abc import Mapping
25
from io import IOBase
26
from subprocess import PIPE
27
from time import sleep
28
from typing import IO, Any, Optional, Union, cast
29
from urllib import request
30
from urllib.error import URLError
31
32
from selenium.common.exceptions import WebDriverException
33
from selenium.types import SubprocessStdAlias
34
from selenium.webdriver.common import utils
35
36
logger = logging.getLogger(__name__)
37
38
39
class Service(ABC):
40
"""The abstract base class for all service objects. Services typically
41
launch a child program in a new process as an interim process to
42
communicate with a browser.
43
44
:param executable: install path of the executable.
45
:param port: Port for the service to run on, defaults to 0 where the operating system will decide.
46
:param log_output: (Optional) int representation of STDOUT/DEVNULL, any IO instance or String path to file.
47
:param env: (Optional) Mapping of environment variables for the new process, defaults to `os.environ`.
48
:param driver_path_env_key: (Optional) Environment variable to use to get the path to the driver executable.
49
"""
50
51
def __init__(
52
self,
53
executable_path: Optional[str] = None,
54
port: int = 0,
55
log_output: Optional[SubprocessStdAlias] = None,
56
env: Optional[Mapping[Any, Any]] = None,
57
driver_path_env_key: Optional[str] = None,
58
**kwargs,
59
) -> None:
60
self.log_output: Optional[Union[int, IOBase]]
61
if isinstance(log_output, str):
62
self.log_output = cast(IOBase, open(log_output, "a+", encoding="utf-8"))
63
elif log_output == subprocess.STDOUT:
64
self.log_output = None
65
elif log_output is None or log_output == subprocess.DEVNULL:
66
self.log_output = subprocess.DEVNULL
67
else:
68
self.log_output = cast(Union[int, IOBase], log_output)
69
70
self.port = port or utils.free_port()
71
# Default value for every python subprocess: subprocess.Popen(..., creationflags=0)
72
self.popen_kw = kwargs.pop("popen_kw", {})
73
self.creation_flags = self.popen_kw.pop("creation_flags", 0)
74
self.env = env or os.environ
75
self.DRIVER_PATH_ENV_KEY = driver_path_env_key
76
self._path = self.env_path() or executable_path
77
78
@property
79
def service_url(self) -> str:
80
"""Gets the url of the Service."""
81
return f"http://{utils.join_host_port('localhost', self.port)}"
82
83
@abstractmethod
84
def command_line_args(self) -> list[str]:
85
"""A List of program arguments (excluding the executable)."""
86
raise NotImplementedError("This method needs to be implemented in a sub class")
87
88
@property
89
def path(self) -> str:
90
return self._path or ""
91
92
@path.setter
93
def path(self, value: str) -> None:
94
self._path = str(value)
95
96
def start(self) -> None:
97
"""Starts the Service.
98
99
:Exceptions:
100
- WebDriverException : Raised either when it can't start the service
101
or when it can't connect to the service
102
"""
103
if self._path is None:
104
raise WebDriverException("Service path cannot be None.")
105
self._start_process(self._path)
106
107
count = 0
108
while True:
109
self.assert_process_still_running()
110
if self.is_connectable():
111
break
112
# sleep increasing: 0.01, 0.06, 0.11, 0.16, 0.21, 0.26, 0.31, 0.36, 0.41, 0.46, 0.5
113
sleep(min(0.01 + 0.05 * count, 0.5))
114
count += 1
115
if count == 70:
116
raise WebDriverException(f"Can not connect to the Service {self._path}")
117
118
def assert_process_still_running(self) -> None:
119
"""Check if the underlying process is still running."""
120
return_code = self.process.poll()
121
if return_code:
122
raise WebDriverException(f"Service {self._path} unexpectedly exited. Status code was: {return_code}")
123
124
def is_connectable(self) -> bool:
125
"""Establishes a socket connection to determine if the service running
126
on the port is accessible."""
127
return utils.is_connectable(self.port)
128
129
def send_remote_shutdown_command(self) -> None:
130
"""Dispatch an HTTP request to the shutdown endpoint for the service in
131
an attempt to stop it."""
132
try:
133
request.urlopen(f"{self.service_url}/shutdown")
134
except URLError:
135
return
136
137
for _ in range(30):
138
if not self.is_connectable():
139
break
140
sleep(1)
141
142
def stop(self) -> None:
143
"""Stops the service."""
144
145
if self.log_output not in {PIPE, subprocess.DEVNULL}:
146
if isinstance(self.log_output, IOBase):
147
self.log_output.close()
148
elif isinstance(self.log_output, int):
149
os.close(self.log_output)
150
151
if self.process is not None and self.process.poll() is None:
152
try:
153
self.send_remote_shutdown_command()
154
except TypeError:
155
pass
156
finally:
157
self._terminate_process()
158
159
def _terminate_process(self) -> None:
160
"""Terminate the child process.
161
162
On POSIX this attempts a graceful SIGTERM followed by a SIGKILL,
163
on a Windows OS kill is an alias to terminate. Terminating does
164
not raise itself if something has gone wrong but (currently)
165
silently ignores errors here.
166
"""
167
try:
168
stdin, stdout, stderr = (
169
self.process.stdin,
170
self.process.stdout,
171
self.process.stderr,
172
)
173
for stream in stdin, stdout, stderr:
174
try:
175
stream.close() # type: ignore
176
except AttributeError:
177
pass
178
self.process.terminate()
179
try:
180
self.process.wait(60)
181
except subprocess.TimeoutExpired:
182
logger.error(
183
"Service process refused to terminate gracefully with SIGTERM, escalating to SIGKILL.",
184
exc_info=True,
185
)
186
self.process.kill()
187
except OSError:
188
logger.error("Error terminating service process.", exc_info=True)
189
190
def __del__(self) -> None:
191
# `subprocess.Popen` doesn't send signal on `__del__`;
192
# so we attempt to close the launched process when `__del__`
193
# is triggered.
194
# do not use globals here; interpreter shutdown may have already cleaned them up
195
# and they would be `None`. This goes for anything this method is referencing internally.
196
try:
197
self.stop()
198
except Exception:
199
pass
200
201
def _start_process(self, path: str) -> None:
202
"""Creates a subprocess by executing the command provided.
203
204
:param cmd: full command to execute
205
"""
206
cmd = [path]
207
cmd.extend(self.command_line_args())
208
close_file_descriptors = self.popen_kw.pop("close_fds", sys.platform != "win32")
209
try:
210
start_info = None
211
if sys.platform == "win32":
212
start_info = subprocess.STARTUPINFO()
213
start_info.dwFlags = subprocess.CREATE_NEW_CONSOLE | subprocess.STARTF_USESHOWWINDOW
214
start_info.wShowWindow = subprocess.SW_HIDE
215
216
self.process = subprocess.Popen(
217
cmd,
218
env=self.env,
219
close_fds=close_file_descriptors,
220
stdout=cast(Optional[Union[int, IO[Any]]], self.log_output),
221
stderr=cast(Optional[Union[int, IO[Any]]], self.log_output),
222
stdin=PIPE,
223
creationflags=self.creation_flags,
224
startupinfo=start_info,
225
**self.popen_kw,
226
)
227
logger.debug(
228
"Started executable: `%s` in a child process with pid: %s using %s to output %s",
229
self._path,
230
self.process.pid,
231
self.creation_flags,
232
self.log_output,
233
)
234
except TypeError:
235
raise
236
except OSError as err:
237
if err.errno == errno.EACCES:
238
if self._path is None:
239
raise WebDriverException("Service path cannot be None.")
240
raise WebDriverException(
241
f"'{os.path.basename(self._path)}' executable may have wrong permissions."
242
) from err
243
raise
244
245
def env_path(self) -> Optional[str]:
246
if self.DRIVER_PATH_ENV_KEY:
247
return os.getenv(self.DRIVER_PATH_ENV_KEY, None)
248
return None
249
250