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