Skip to content

Commit bf271b7

Browse files
committed
webbrowser: launch default browser more often on linux
especially for file:// URLs - add support for `gtk-launch` and `gio launch` if default browser is found via `xdg-settings` - add support for `exo-open --target WebBrowser` - explicitly prevent xdg-open and other non-browser-specific openers from launching `file://` URLs
1 parent 3d40317 commit bf271b7

File tree

4 files changed

+130
-6
lines changed

4 files changed

+130
-6
lines changed

Doc/library/webbrowser.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,11 @@ The following functions are defined:
7474

7575
Returns ``True`` if a browser was successfully launched, ``False`` otherwise.
7676

77-
Note that on some platforms, trying to open a filename using this function,
77+
Note that on some platforms, trying to open a filename (``'./path.html'``) using this function,
7878
may work and start the operating system's associated program. However, this
7979
is neither supported nor portable.
80+
``'file://...'`` URLs, on the other hand, should work and consistently
81+
launch a browser.
8082

8183
.. audit-event:: webbrowser.open url webbrowser.open
8284

@@ -201,6 +203,14 @@ Notes:
201203
.. versionchanged:: 3.13
202204
Support for iOS has been added.
203205

206+
.. versionadded:: next
207+
Support for launching the XDG default browser via ``gtk-launch`` or ``gio launch`` on POSIX systems,
208+
and ``exo-open`` in XFCE environments.
209+
210+
.. versionchanged:: next
211+
``file://`` URLs should now open more reliably in browsers on all platforms,
212+
instead of opening the default application associated with the file type.
213+
204214
Here are some simple examples::
205215

206216
url = 'https://docs.python.org/'

Lib/test/test_webbrowser.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,25 @@ def test_open(self):
6565
options=[],
6666
arguments=[URL])
6767

68+
def test_supports_file(self):
69+
self._test('open',
70+
args=["file:///tmp/file"],
71+
options=[],
72+
arguments=["file:///tmp/file"])
73+
74+
def test_not_supports_file(self):
75+
popen = PopenMock()
76+
support.patch(self, subprocess, 'Popen', popen)
77+
browser = self.browser_class("open")
78+
browser._supports_file = False
79+
assert not browser.open("file:///some/file")
80+
assert subprocess.Popen.call_count == 0
81+
url = "https://some-url"
82+
browser.open(url)
83+
assert subprocess.Popen.call_count == 1
84+
popen_args = subprocess.Popen.call_args[0][0]
85+
self.assertEqual(popen_args, ["open", url])
86+
6887

6988
class BackgroundBrowserCommandTest(CommandTestMixin, unittest.TestCase):
7089

@@ -75,6 +94,25 @@ def test_open(self):
7594
options=[],
7695
arguments=[URL])
7796

97+
def test_supports_file(self):
98+
self._test('open',
99+
args=["file:///tmp/file"],
100+
options=[],
101+
arguments=["file:///tmp/file"])
102+
103+
def test_not_supports_file(self):
104+
popen = PopenMock()
105+
support.patch(self, subprocess, 'Popen', popen)
106+
browser = self.browser_class("open")
107+
browser._supports_file = False
108+
assert not browser.open("file:///some/file")
109+
assert subprocess.Popen.call_count == 0
110+
url = "https://some-url"
111+
browser.open(url)
112+
assert subprocess.Popen.call_count == 1
113+
popen_args = subprocess.Popen.call_args[0][0]
114+
self.assertEqual(popen_args, ["open", url])
115+
78116

79117
class ChromeCommandTest(CommandTestMixin, unittest.TestCase):
80118

Lib/webbrowser.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ class GenericBrowser(BaseBrowser):
168168
"""Class for all browsers started with a command
169169
and without remote functionality."""
170170

171-
def __init__(self, name):
171+
def __init__(self, name, /, _supports_file=True):
172172
if isinstance(name, str):
173173
self.name = name
174174
self.args = ["%s"]
@@ -177,9 +177,20 @@ def __init__(self, name):
177177
self.name = name[0]
178178
self.args = name[1:]
179179
self.basename = os.path.basename(self.name)
180+
# whether it supports file:// URLs
181+
# set to False for generic openers like xdg-open,
182+
# which do not launch webbrowsers reliably
183+
self._supports_file = _supports_file
180184

181185
def open(self, url, new=0, autoraise=True):
182186
sys.audit("webbrowser.open", url)
187+
188+
if not self._supports_file:
189+
# skip me for `file://` URLs for generic openers (e.g. xdg-open)
190+
proto, _sep, _rest = url.partition(":")
191+
if _sep and proto.lower() == "file":
192+
return False
193+
183194
cmdline = [self.name] + [arg.replace("%s", url)
184195
for arg in self.args]
185196
try:
@@ -197,6 +208,12 @@ class BackgroundBrowser(GenericBrowser):
197208
background."""
198209

199210
def open(self, url, new=0, autoraise=True):
211+
if not self._supports_file:
212+
# skip me for `file://` URLs for generic openers (e.g. xdg-open)
213+
proto, _sep, _rest = url.partition(":")
214+
if _sep and proto.lower() == "file":
215+
return False
216+
200217
cmdline = [self.name] + [arg.replace("%s", url)
201218
for arg in self.args]
202219
sys.audit("webbrowser.open", url)
@@ -415,34 +432,87 @@ class Edge(UnixBrowser):
415432
# Platform support for Unix
416433
#
417434

435+
436+
def _locate_xdg_desktop(name: str) -> str | None:
437+
"""Locate .desktop file by name
438+
439+
Returns absolute path to .desktop file found on $XDG_DATA search path
440+
or None if no matching .desktop file is found.
441+
442+
Needed for `gio launch` support.
443+
"""
444+
if not name.endswith(".desktop"):
445+
# ensure it ends in .desktop
446+
name += ".desktop"
447+
xdg_data_home = os.environ.get("XDG_DATA_HOME") or os.path.expanduser(
448+
"~/.local/share"
449+
)
450+
xdg_data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share/:/usr/share/"
451+
all_data_dirs = [xdg_data_home]
452+
all_data_dirs.extend(xdg_data_dirs.split(os.pathsep))
453+
for data_dir in all_data_dirs:
454+
desktop_path = os.path.join(data_dir, "applications", name)
455+
if os.path.exists(desktop_path):
456+
return desktop_path
457+
return None
458+
418459
# These are the right tests because all these Unix browsers require either
419460
# a console terminal or an X display to run.
420461

421462
def register_X_browsers():
422463

464+
# use gtk-launch to launch preferred browser by name, if found
465+
# this should be _before_ xdg-open, which doesn't necessarily launch a browser
466+
if _os_preferred_browser and shutil.which("gtk-launch"):
467+
register(
468+
"gtk-launch",
469+
None,
470+
BackgroundBrowser(["gtk-launch", _os_preferred_browser, "%s"]),
471+
)
472+
423473
# use xdg-open if around
424474
if shutil.which("xdg-open"):
425-
register("xdg-open", None, BackgroundBrowser("xdg-open"))
475+
# `xdg-open` does NOT guarantee a browser is launched,
476+
# so skip it for `file://`
477+
register("xdg-open", None, BackgroundBrowser("xdg-open", _supports_file=False))
478+
426479

427-
# Opens an appropriate browser for the URL scheme according to
480+
# Opens the default application for the URL scheme according to
428481
# freedesktop.org settings (GNOME, KDE, XFCE, etc.)
429482
if shutil.which("gio"):
430-
register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"]))
483+
if _os_preferred_browser:
484+
absolute_browser = _locate_xdg_desktop(_os_preferred_browser)
485+
if absolute_browser:
486+
register(
487+
"gio-launch",
488+
None,
489+
BackgroundBrowser(["gio", "launch", absolute_browser, "%s"]),
490+
)
491+
# `gio open` does NOT guarantee a browser is launched
492+
register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"], _supports_file=False))
431493

432494
xdg_desktop = os.getenv("XDG_CURRENT_DESKTOP", "").split(":")
433495

434496
# The default GNOME3 browser
435497
if (("GNOME" in xdg_desktop or
436498
"GNOME_DESKTOP_SESSION_ID" in os.environ) and
437499
shutil.which("gvfs-open")):
438-
register("gvfs-open", None, BackgroundBrowser("gvfs-open"))
500+
register("gvfs-open", None, BackgroundBrowser("gvfs-open", _supports_file=False))
439501

440502
# The default KDE browser
441503
if (("KDE" in xdg_desktop or
442504
"KDE_FULL_SESSION" in os.environ) and
443505
shutil.which("kfmclient")):
444506
register("kfmclient", Konqueror, Konqueror("kfmclient"))
445507

508+
# The default XFCE browser
509+
if "XFCE" in xdg_desktop and shutil.which("exo-open"):
510+
register(
511+
"exo-open",
512+
None,
513+
BackgroundBrowser(["exo-open", "--launch", "WebBrowser", "%s"]),
514+
)
515+
446516
# Common symbolic link for the default X11 browser
447517
if shutil.which("x-www-browser"):
448518
register("x-www-browser", None, BackgroundBrowser("x-www-browser"))
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
:func:`webbrowser.open` should launch default webbrowsers for URLs that are
2+
not ``http[s]://`` more often (especially ``file://``,
3+
where the default application by file type was often launched, instead of a browser).
4+
This works by adding support for ``gtk-launch`` and ``gio
5+
launch``,
6+
and making sure generic application launchers like ``xdg-open`` are not used for ``file://`` URLs.

0 commit comments

Comments
 (0)