From bf271b745bb8b2770ad6ca33f557c7013d97ad66 Mon Sep 17 00:00:00 2001 From: Min RK Date: Mon, 24 Feb 2025 14:02:46 +0100 Subject: [PATCH 1/5] 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 --- Doc/library/webbrowser.rst | 12 ++- Lib/test/test_webbrowser.py | 38 +++++++++ Lib/webbrowser.py | 80 +++++++++++++++++-- ...-02-24-13-55-54.gh-issue-128540.Pc4Pa4.rst | 6 ++ 4 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-02-24-13-55-54.gh-issue-128540.Pc4Pa4.rst diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 2d19c514ce43b6..1b54cc83d461cc 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -74,9 +74,11 @@ The following functions are defined: Returns ``True`` if a browser was successfully launched, ``False`` otherwise. - Note that on some platforms, trying to open a filename using this function, + Note that on some platforms, trying to open a filename (``'./path.html'``) using this function, may work and start the operating system's associated program. However, this is neither supported nor portable. + ``'file://...'`` URLs, on the other hand, should work and consistently + launch a browser. .. audit-event:: webbrowser.open url webbrowser.open @@ -201,6 +203,14 @@ Notes: .. versionchanged:: 3.13 Support for iOS has been added. +.. versionadded:: next + Support for launching the XDG default browser via ``gtk-launch`` or ``gio launch`` on POSIX systems, + and ``exo-open`` in XFCE environments. + +.. versionchanged:: next + ``file://`` URLs should now open more reliably in browsers on all platforms, + instead of opening the default application associated with the file type. + Here are some simple examples:: url = 'https://docs.python.org/' diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index 4fcbc5c2e59ea3..59156935a757c9 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -65,6 +65,25 @@ def test_open(self): options=[], arguments=[URL]) + def test_supports_file(self): + self._test('open', + args=["file:///tmp/file"], + options=[], + arguments=["file:///tmp/file"]) + + def test_not_supports_file(self): + popen = PopenMock() + support.patch(self, subprocess, 'Popen', popen) + browser = self.browser_class("open") + browser._supports_file = False + assert not browser.open("file:///some/file") + assert subprocess.Popen.call_count == 0 + url = "https://some-url" + browser.open(url) + assert subprocess.Popen.call_count == 1 + popen_args = subprocess.Popen.call_args[0][0] + self.assertEqual(popen_args, ["open", url]) + class BackgroundBrowserCommandTest(CommandTestMixin, unittest.TestCase): @@ -75,6 +94,25 @@ def test_open(self): options=[], arguments=[URL]) + def test_supports_file(self): + self._test('open', + args=["file:///tmp/file"], + options=[], + arguments=["file:///tmp/file"]) + + def test_not_supports_file(self): + popen = PopenMock() + support.patch(self, subprocess, 'Popen', popen) + browser = self.browser_class("open") + browser._supports_file = False + assert not browser.open("file:///some/file") + assert subprocess.Popen.call_count == 0 + url = "https://some-url" + browser.open(url) + assert subprocess.Popen.call_count == 1 + popen_args = subprocess.Popen.call_args[0][0] + self.assertEqual(popen_args, ["open", url]) + class ChromeCommandTest(CommandTestMixin, unittest.TestCase): diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index d2efc72113a917..005ecfcf4fe1c6 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -168,7 +168,7 @@ class GenericBrowser(BaseBrowser): """Class for all browsers started with a command and without remote functionality.""" - def __init__(self, name): + def __init__(self, name, /, _supports_file=True): if isinstance(name, str): self.name = name self.args = ["%s"] @@ -177,9 +177,20 @@ def __init__(self, name): self.name = name[0] self.args = name[1:] self.basename = os.path.basename(self.name) + # whether it supports file:// URLs + # set to False for generic openers like xdg-open, + # which do not launch webbrowsers reliably + self._supports_file = _supports_file def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + + if not self._supports_file: + # skip me for `file://` URLs for generic openers (e.g. xdg-open) + proto, _sep, _rest = url.partition(":") + if _sep and proto.lower() == "file": + return False + cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] try: @@ -197,6 +208,12 @@ class BackgroundBrowser(GenericBrowser): background.""" def open(self, url, new=0, autoraise=True): + if not self._supports_file: + # skip me for `file://` URLs for generic openers (e.g. xdg-open) + proto, _sep, _rest = url.partition(":") + if _sep and proto.lower() == "file": + return False + cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] sys.audit("webbrowser.open", url) @@ -415,19 +432,64 @@ class Edge(UnixBrowser): # Platform support for Unix # + +def _locate_xdg_desktop(name: str) -> str | None: + """Locate .desktop file by name + + Returns absolute path to .desktop file found on $XDG_DATA search path + or None if no matching .desktop file is found. + + Needed for `gio launch` support. + """ + if not name.endswith(".desktop"): + # ensure it ends in .desktop + name += ".desktop" + xdg_data_home = os.environ.get("XDG_DATA_HOME") or os.path.expanduser( + "~/.local/share" + ) + xdg_data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share/:/usr/share/" + all_data_dirs = [xdg_data_home] + all_data_dirs.extend(xdg_data_dirs.split(os.pathsep)) + for data_dir in all_data_dirs: + desktop_path = os.path.join(data_dir, "applications", name) + if os.path.exists(desktop_path): + return desktop_path + return None + # These are the right tests because all these Unix browsers require either # a console terminal or an X display to run. def register_X_browsers(): + # use gtk-launch to launch preferred browser by name, if found + # this should be _before_ xdg-open, which doesn't necessarily launch a browser + if _os_preferred_browser and shutil.which("gtk-launch"): + register( + "gtk-launch", + None, + BackgroundBrowser(["gtk-launch", _os_preferred_browser, "%s"]), + ) + # use xdg-open if around if shutil.which("xdg-open"): - register("xdg-open", None, BackgroundBrowser("xdg-open")) + # `xdg-open` does NOT guarantee a browser is launched, + # so skip it for `file://` + register("xdg-open", None, BackgroundBrowser("xdg-open", _supports_file=False)) + - # Opens an appropriate browser for the URL scheme according to + # Opens the default application for the URL scheme according to # freedesktop.org settings (GNOME, KDE, XFCE, etc.) if shutil.which("gio"): - register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"])) + if _os_preferred_browser: + absolute_browser = _locate_xdg_desktop(_os_preferred_browser) + if absolute_browser: + register( + "gio-launch", + None, + BackgroundBrowser(["gio", "launch", absolute_browser, "%s"]), + ) + # `gio open` does NOT guarantee a browser is launched + register("gio", None, BackgroundBrowser(["gio", "open", "--", "%s"], _supports_file=False)) xdg_desktop = os.getenv("XDG_CURRENT_DESKTOP", "").split(":") @@ -435,7 +497,7 @@ def register_X_browsers(): if (("GNOME" in xdg_desktop or "GNOME_DESKTOP_SESSION_ID" in os.environ) and shutil.which("gvfs-open")): - register("gvfs-open", None, BackgroundBrowser("gvfs-open")) + register("gvfs-open", None, BackgroundBrowser("gvfs-open", _supports_file=False)) # The default KDE browser if (("KDE" in xdg_desktop or @@ -443,6 +505,14 @@ def register_X_browsers(): shutil.which("kfmclient")): register("kfmclient", Konqueror, Konqueror("kfmclient")) + # The default XFCE browser + if "XFCE" in xdg_desktop and shutil.which("exo-open"): + register( + "exo-open", + None, + BackgroundBrowser(["exo-open", "--launch", "WebBrowser", "%s"]), + ) + # Common symbolic link for the default X11 browser if shutil.which("x-www-browser"): register("x-www-browser", None, BackgroundBrowser("x-www-browser")) diff --git a/Misc/NEWS.d/next/Library/2025-02-24-13-55-54.gh-issue-128540.Pc4Pa4.rst b/Misc/NEWS.d/next/Library/2025-02-24-13-55-54.gh-issue-128540.Pc4Pa4.rst new file mode 100644 index 00000000000000..b555182de4d742 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-02-24-13-55-54.gh-issue-128540.Pc4Pa4.rst @@ -0,0 +1,6 @@ +:func:`webbrowser.open` should launch default webbrowsers for URLs that are +not ``http[s]://`` more often (especially ``file://``, +where the default application by file type was often launched, instead of a browser). +This works by adding support for ``gtk-launch`` and ``gio +launch``, +and making sure generic application launchers like ``xdg-open`` are not used for ``file://`` URLs. From 72bd9a4deed17586fcf8a8672fea252f69a7d2c1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Tue, 25 Feb 2025 12:44:00 +0100 Subject: [PATCH 2/5] correct keyword-only syntax --- Lib/webbrowser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 005ecfcf4fe1c6..ae0cdbf83e6cd9 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -168,7 +168,7 @@ class GenericBrowser(BaseBrowser): """Class for all browsers started with a command and without remote functionality.""" - def __init__(self, name, /, _supports_file=True): + def __init__(self, name, *, _supports_file=True): if isinstance(name, str): self.name = name self.args = ["%s"] From 90f75747ecc1de955c20d61c4401b12ef2880f39 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 27 Feb 2025 10:48:50 +0100 Subject: [PATCH 3/5] webbrowser: add kioclient support --- Doc/library/webbrowser.rst | 3 ++- Lib/webbrowser.py | 9 ++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 1b54cc83d461cc..76e9f452bcdead 100644 --- a/Doc/library/webbrowser.rst +++ b/Doc/library/webbrowser.rst @@ -205,7 +205,8 @@ Notes: .. versionadded:: next Support for launching the XDG default browser via ``gtk-launch`` or ``gio launch`` on POSIX systems, - and ``exo-open`` in XFCE environments. + ``exo-open`` in XFCE environments, + and ``kioclient exec`` in KDE environments. .. versionchanged:: next ``file://`` URLs should now open more reliably in browsers on all platforms, diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index ae0cdbf83e6cd9..9d1f4f0b9d8257 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -501,9 +501,12 @@ def register_X_browsers(): # The default KDE browser if (("KDE" in xdg_desktop or - "KDE_FULL_SESSION" in os.environ) and - shutil.which("kfmclient")): - register("kfmclient", Konqueror, Konqueror("kfmclient")) + "KDE_FULL_SESSION" in os.environ): + if shutil.which("kioclient"): + # launch URL with http[s] handler + register("kioclient", None, BackgroundBrowser(["kioclient", "exec", "%s", "x-scheme-handler/https"])) + if shutil.which("kfmclient")): + register("kfmclient", Konqueror, Konqueror("kfmclient")) # The default XFCE browser if "XFCE" in xdg_desktop and shutil.which("exo-open"): From 5926c67bdcded0b0e174ec490c8b3fe0e0a1e130 Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 27 Feb 2025 10:49:12 +0100 Subject: [PATCH 4/5] webbrowser: accept gtk4-launch --- Lib/webbrowser.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 9d1f4f0b9d8257..0af3f1fb4fac1d 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -461,14 +461,16 @@ def _locate_xdg_desktop(name: str) -> str | None: def register_X_browsers(): - # use gtk-launch to launch preferred browser by name, if found + # use gtk[4]-launch to launch preferred browser by name, if found # this should be _before_ xdg-open, which doesn't necessarily launch a browser - if _os_preferred_browser and shutil.which("gtk-launch"): - register( - "gtk-launch", - None, - BackgroundBrowser(["gtk-launch", _os_preferred_browser, "%s"]), - ) + if _os_preferred_browser: + for gtk_launch in ("gtk4-launch", "gtk-launch"): + if shutil.which(gtk_launch): + register( + gtk_launch, + None, + BackgroundBrowser([gtk_launch, _os_preferred_browser, "%s"]), + ) # use xdg-open if around if shutil.which("xdg-open"): From 8e191e6560f24e9c14a2341d9608e9dffc72d8ff Mon Sep 17 00:00:00 2001 From: Min RK Date: Thu, 27 Feb 2025 11:03:28 +0100 Subject: [PATCH 5/5] fix parentheses --- Lib/webbrowser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 0af3f1fb4fac1d..44e8e282268ef9 100644 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -502,12 +502,12 @@ def register_X_browsers(): register("gvfs-open", None, BackgroundBrowser("gvfs-open", _supports_file=False)) # The default KDE browser - if (("KDE" in xdg_desktop or + if ("KDE" in xdg_desktop or "KDE_FULL_SESSION" in os.environ): if shutil.which("kioclient"): # launch URL with http[s] handler register("kioclient", None, BackgroundBrowser(["kioclient", "exec", "%s", "x-scheme-handler/https"])) - if shutil.which("kfmclient")): + if shutil.which("kfmclient"): register("kfmclient", Konqueror, Konqueror("kfmclient")) # The default XFCE browser