diff --git a/Doc/library/webbrowser.rst b/Doc/library/webbrowser.rst index 2d19c514ce43b6..76e9f452bcdead 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,15 @@ 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, + ``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, + 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..44e8e282268ef9 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,66 @@ 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[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: + 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"): - 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,13 +499,24 @@ 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 - "KDE_FULL_SESSION" in os.environ) and - shutil.which("kfmclient")): - register("kfmclient", Konqueror, Konqueror("kfmclient")) + 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"): + 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"): 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.