Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
SeleniumHQ
GitHub Repository: SeleniumHQ/Selenium
Path: blob/trunk/py/selenium/webdriver/firefox/firefox_profile.py
4095 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 base64
19
import copy
20
import json
21
import os
22
import re
23
import shutil
24
import sys
25
import tempfile
26
import warnings
27
import zipfile
28
from io import BytesIO
29
from xml.dom import minidom
30
31
from typing_extensions import deprecated
32
33
from selenium.common.exceptions import WebDriverException
34
35
WEBDRIVER_PREFERENCES = "webdriver_prefs.json"
36
37
38
@deprecated("Addons must be added after starting the session")
39
class AddonFormatError(Exception):
40
"""Exception for not well-formed add-on manifest files."""
41
42
43
class FirefoxProfile:
44
DEFAULT_PREFERENCES = None
45
46
def __init__(self, profile_directory=None):
47
"""Initialises a new instance of a Firefox Profile.
48
49
Args:
50
profile_directory: Directory of profile that you want to use. If a
51
directory is passed in it will be cloned and the cloned directory
52
will be used by the driver when instantiated.
53
This defaults to None and will create a new
54
directory when object is created.
55
"""
56
self._desired_preferences = {}
57
if profile_directory:
58
newprof = os.path.join(tempfile.mkdtemp(), "webdriver-py-profilecopy")
59
shutil.copytree(
60
profile_directory, newprof, ignore=shutil.ignore_patterns("parent.lock", "lock", ".parentlock")
61
)
62
self._profile_dir = newprof
63
os.chmod(self._profile_dir, 0o755)
64
else:
65
self._profile_dir = tempfile.mkdtemp()
66
if not FirefoxProfile.DEFAULT_PREFERENCES:
67
with open(
68
os.path.join(os.path.dirname(__file__), WEBDRIVER_PREFERENCES), encoding="utf-8"
69
) as default_prefs:
70
FirefoxProfile.DEFAULT_PREFERENCES = json.load(default_prefs)
71
72
self._desired_preferences = copy.deepcopy(FirefoxProfile.DEFAULT_PREFERENCES["mutable"])
73
for key, value in FirefoxProfile.DEFAULT_PREFERENCES["frozen"].items():
74
self._desired_preferences[key] = value
75
76
# Public Methods
77
def set_preference(self, key, value):
78
"""Sets the preference that we want in the profile."""
79
self._desired_preferences[key] = value
80
81
@deprecated("Addons must be added after starting the session")
82
def add_extension(self, extension=None):
83
self._install_extension(extension)
84
85
def update_preferences(self):
86
"""Writes the desired user prefs to disk."""
87
user_prefs = os.path.join(self._profile_dir, "user.js")
88
if os.path.isfile(user_prefs):
89
os.chmod(user_prefs, 0o644)
90
self._read_existing_userjs(user_prefs)
91
with open(user_prefs, "w", encoding="utf-8") as f:
92
for key, value in self._desired_preferences.items():
93
f.write(f'user_pref("{key}", {json.dumps(value)});\n')
94
95
# Properties
96
97
@property
98
def path(self):
99
"""Gets the profile directory that is currently being used."""
100
return self._profile_dir
101
102
@property
103
@deprecated("The port is stored in the Service class")
104
def port(self):
105
"""Gets the port that WebDriver is working on."""
106
return self._port
107
108
@port.setter
109
@deprecated("The port is stored in the Service class")
110
def port(self, port) -> None:
111
"""Sets the port that WebDriver will be running on."""
112
if not isinstance(port, int):
113
raise WebDriverException("Port needs to be an integer")
114
try:
115
port = int(port)
116
if port < 1 or port > 65535:
117
raise WebDriverException("Port number must be in the range 1..65535")
118
except (ValueError, TypeError):
119
raise WebDriverException("Port needs to be an integer")
120
self._port = port
121
self.set_preference("webdriver_firefox_port", self._port)
122
123
@property
124
@deprecated("Allowing untrusted certs is toggled in the Options class")
125
def accept_untrusted_certs(self):
126
return self._desired_preferences["webdriver_accept_untrusted_certs"]
127
128
@accept_untrusted_certs.setter
129
@deprecated("Allowing untrusted certs is toggled in the Options class")
130
def accept_untrusted_certs(self, value) -> None:
131
if not isinstance(value, bool):
132
raise WebDriverException("Please pass in a Boolean to this call")
133
self.set_preference("webdriver_accept_untrusted_certs", value)
134
135
@property
136
@deprecated("Allowing untrusted certs is toggled in the Options class")
137
def assume_untrusted_cert_issuer(self):
138
return self._desired_preferences["webdriver_assume_untrusted_issuer"]
139
140
@assume_untrusted_cert_issuer.setter
141
@deprecated("Allowing untrusted certs is toggled in the Options class")
142
def assume_untrusted_cert_issuer(self, value) -> None:
143
if not isinstance(value, bool):
144
raise WebDriverException("Please pass in a Boolean to this call")
145
146
self.set_preference("webdriver_assume_untrusted_issuer", value)
147
148
@property
149
def encoded(self) -> str:
150
"""Update preferences and create a zipped, base64-encoded profile directory string."""
151
if self._desired_preferences:
152
self.update_preferences()
153
fp = BytesIO()
154
with zipfile.ZipFile(fp, "w", zipfile.ZIP_DEFLATED, strict_timestamps=False) as zipped:
155
path_root = len(self.path) + 1 # account for trailing slash
156
for base, _, files in os.walk(self.path):
157
for fyle in files:
158
filename = os.path.join(base, fyle)
159
zipped.write(filename, filename[path_root:])
160
return base64.b64encode(fp.getvalue()).decode("UTF-8")
161
162
def _read_existing_userjs(self, userjs):
163
"""Read existing preferences and add them to the desired preference dictionary."""
164
pref_pattern = re.compile(r'user_pref\("(.*)",\s(.*)\)')
165
with open(userjs, encoding="utf-8") as f:
166
for usr in f:
167
matches = pref_pattern.search(usr)
168
try:
169
self._desired_preferences[matches.group(1)] = json.loads(matches.group(2))
170
except Exception:
171
warnings.warn(
172
f"(skipping) failed to json.loads existing preference: {matches.group(1) + matches.group(2)}"
173
)
174
175
@deprecated("Addons must be added after starting the session")
176
def _install_extension(self, addon, unpack=True):
177
"""Install addon from a filepath, URL, or directory of addons in the profile.
178
179
Args:
180
addon: url, absolute path to .xpi, or directory of addons
181
unpack: whether to unpack unless specified otherwise in the install.rdf
182
"""
183
tmpdir = None
184
xpifile = None
185
if addon.endswith(".xpi"):
186
tmpdir = tempfile.mkdtemp(suffix="." + os.path.split(addon)[-1])
187
compressed_file = zipfile.ZipFile(addon, "r")
188
for name in compressed_file.namelist():
189
if name.endswith("/"):
190
if not os.path.isdir(os.path.join(tmpdir, name)):
191
os.makedirs(os.path.join(tmpdir, name))
192
else:
193
if not os.path.isdir(os.path.dirname(os.path.join(tmpdir, name))):
194
os.makedirs(os.path.dirname(os.path.join(tmpdir, name)))
195
data = compressed_file.read(name)
196
with open(os.path.join(tmpdir, name), "wb") as f:
197
f.write(data)
198
xpifile = addon
199
addon = tmpdir
200
201
# determine the addon id
202
addon_details = self._addon_details(addon)
203
addon_id = addon_details.get("id")
204
assert addon_id, f"The addon id could not be found: {addon}"
205
206
# copy the addon to the profile
207
extensions_dir = os.path.join(self._profile_dir, "extensions")
208
addon_path = os.path.join(extensions_dir, addon_id)
209
if not unpack and not addon_details["unpack"] and xpifile:
210
if not os.path.exists(extensions_dir):
211
os.makedirs(extensions_dir)
212
os.chmod(extensions_dir, 0o755)
213
shutil.copy(xpifile, addon_path + ".xpi")
214
else:
215
if not os.path.exists(addon_path):
216
shutil.copytree(addon, addon_path, symlinks=True)
217
218
# remove the temporary directory, if any
219
if tmpdir:
220
shutil.rmtree(tmpdir)
221
222
@deprecated("Addons must be added after starting the session")
223
def _addon_details(self, addon_path):
224
"""Returns a dictionary of details about the addon.
225
226
Args:
227
addon_path: path to the add-on directory or XPI
228
229
Returns:
230
A dictionary containing:
231
232
{
233
"id": "[email protected]", # id of the addon
234
"version": "1.4", # version of the addon
235
"name": "Rainbow", # name of the addon
236
"unpack": False,
237
} # whether to unpack the addon
238
"""
239
details = {"id": None, "unpack": False, "name": None, "version": None}
240
241
def get_namespace_id(doc, url):
242
attributes = doc.documentElement.attributes
243
namespace = ""
244
for i in range(attributes.length):
245
if attributes.item(i).value == url:
246
if ":" in attributes.item(i).name:
247
# If the namespace is not the default one remove 'xlmns:'
248
namespace = attributes.item(i).name.split(":")[1] + ":"
249
break
250
return namespace
251
252
def get_text(element):
253
"""Retrieve the text value of a given node."""
254
rc = []
255
for node in element.childNodes:
256
if node.nodeType == node.TEXT_NODE:
257
rc.append(node.data)
258
return "".join(rc).strip()
259
260
def parse_manifest_json(content):
261
"""Extract details from the contents of a WebExtensions manifest.json file."""
262
manifest = json.loads(content)
263
try:
264
id = manifest["applications"]["gecko"]["id"]
265
except KeyError:
266
id = manifest["name"].replace(" ", "") + "@" + manifest["version"]
267
return {
268
"id": id,
269
"version": manifest["version"],
270
"name": manifest["version"],
271
"unpack": False,
272
}
273
274
if not os.path.exists(addon_path):
275
raise OSError(f"Add-on path does not exist: {addon_path}")
276
277
try:
278
if zipfile.is_zipfile(addon_path):
279
with zipfile.ZipFile(addon_path, "r") as compressed_file:
280
if "manifest.json" in compressed_file.namelist():
281
return parse_manifest_json(compressed_file.read("manifest.json"))
282
283
manifest = compressed_file.read("install.rdf")
284
elif os.path.isdir(addon_path):
285
manifest_json_filename = os.path.join(addon_path, "manifest.json")
286
if os.path.exists(manifest_json_filename):
287
with open(manifest_json_filename, encoding="utf-8") as f:
288
return parse_manifest_json(f.read())
289
290
with open(os.path.join(addon_path, "install.rdf"), encoding="utf-8") as f:
291
manifest = f.read()
292
else:
293
raise OSError(f"Add-on path is neither an XPI nor a directory: {addon_path}")
294
except (OSError, KeyError) as e:
295
raise AddonFormatError(str(e), sys.exc_info()[2])
296
297
try:
298
doc = minidom.parseString(manifest)
299
300
# Get the namespaces abbreviations
301
em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")
302
rdf = get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
303
304
description = doc.getElementsByTagName(rdf + "Description").item(0)
305
if not description:
306
description = doc.getElementsByTagName("Description").item(0)
307
for node in description.childNodes:
308
# Remove the namespace prefix from the tag for comparison
309
entry = node.nodeName.replace(em, "")
310
if entry in details:
311
details.update({entry: get_text(node)})
312
if not details.get("id"):
313
for i in range(description.attributes.length):
314
attribute = description.attributes.item(i)
315
if attribute.name == em + "id":
316
details.update({"id": attribute.value})
317
except Exception as e:
318
raise AddonFormatError(str(e), sys.exc_info()[2])
319
320
# turn unpack into a true/false value
321
if isinstance(details["unpack"], str):
322
details["unpack"] = details["unpack"].lower() == "true"
323
324
# If no ID is set, the add-on is invalid
325
if not details.get("id"):
326
raise AddonFormatError("Add-on id could not be found.")
327
328
return details
329
330