gh-68583: webbrowser: replace getopt with argparse, add long options (#117047)

This commit is contained in:
Hugo van Kemenade 2024-04-13 17:56:56 +03:00 committed by GitHub
parent 022ba6d161
commit 56ed979d04
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 134 additions and 54 deletions

View file

@ -42,9 +42,12 @@ a new tab, with the browser being brought to the foreground. The use of the
The script :program:`webbrowser` can be used as a command-line interface for the The script :program:`webbrowser` can be used as a command-line interface for the
module. It accepts a URL as the argument. It accepts the following optional module. It accepts a URL as the argument. It accepts the following optional
parameters: ``-n`` opens the URL in a new browser window, if possible; parameters:
``-t`` opens the URL in a new browser page ("tab"). The options are,
naturally, mutually exclusive. Usage example:: * ``-n``/``--new-window`` opens the URL in a new browser window, if possible.
* ``-t``/``--new-tab`` opens the URL in a new browser page ("tab").
The options are, naturally, mutually exclusive. Usage example::
python -m webbrowser -t "https://www.python.org" python -m webbrowser -t "https://www.python.org"

View file

@ -1,15 +1,17 @@
import webbrowser
import unittest
import os import os
import sys import re
import shlex
import subprocess import subprocess
from unittest import mock import sys
import unittest
import webbrowser
from test import support from test import support
from test.support import is_apple_mobile
from test.support import import_helper from test.support import import_helper
from test.support import is_apple_mobile
from test.support import os_helper from test.support import os_helper
from test.support import requires_subprocess from test.support import requires_subprocess
from test.support import threading_helper from test.support import threading_helper
from unittest import mock
# The webbrowser module uses threading locks # The webbrowser module uses threading locks
threading_helper.requires_working_threading(module=True) threading_helper.requires_working_threading(module=True)
@ -98,6 +100,15 @@ class ChromeCommandTest(CommandTestMixin, unittest.TestCase):
options=[], options=[],
arguments=[URL]) arguments=[URL])
def test_open_bad_new_parameter(self):
with self.assertRaisesRegex(webbrowser.Error,
re.escape("Bad 'new' parameter to open(); "
"expected 0, 1, or 2, got 999")):
self._test('open',
options=[],
arguments=[URL],
kw=dict(new=999))
class EdgeCommandTest(CommandTestMixin, unittest.TestCase): class EdgeCommandTest(CommandTestMixin, unittest.TestCase):
@ -205,22 +216,22 @@ class ELinksCommandTest(CommandTestMixin, unittest.TestCase):
def test_open(self): def test_open(self):
self._test('open', options=['-remote'], self._test('open', options=['-remote'],
arguments=['openURL({})'.format(URL)]) arguments=[f'openURL({URL})'])
def test_open_with_autoraise_false(self): def test_open_with_autoraise_false(self):
self._test('open', self._test('open',
options=['-remote'], options=['-remote'],
arguments=['openURL({})'.format(URL)]) arguments=[f'openURL({URL})'])
def test_open_new(self): def test_open_new(self):
self._test('open_new', self._test('open_new',
options=['-remote'], options=['-remote'],
arguments=['openURL({},new-window)'.format(URL)]) arguments=[f'openURL({URL},new-window)'])
def test_open_new_tab(self): def test_open_new_tab(self):
self._test('open_new_tab', self._test('open_new_tab',
options=['-remote'], options=['-remote'],
arguments=['openURL({},new-tab)'.format(URL)]) arguments=[f'openURL({URL},new-tab)'])
@unittest.skipUnless(sys.platform == "ios", "Test only applicable to iOS") @unittest.skipUnless(sys.platform == "ios", "Test only applicable to iOS")
@ -342,7 +353,6 @@ class BrowserRegistrationTest(unittest.TestCase):
def test_register_preferred(self): def test_register_preferred(self):
self._check_registration(preferred=True) self._check_registration(preferred=True)
@unittest.skipUnless(sys.platform == "darwin", "macOS specific test") @unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
def test_no_xdg_settings_on_macOS(self): def test_no_xdg_settings_on_macOS(self):
# On macOS webbrowser should not use xdg-settings to # On macOS webbrowser should not use xdg-settings to
@ -423,5 +433,62 @@ class ImportTest(unittest.TestCase):
self.assertEqual(webbrowser.get().name, sys.executable) self.assertEqual(webbrowser.get().name, sys.executable)
if __name__=='__main__': class CliTest(unittest.TestCase):
def test_parse_args(self):
for command, url, new_win in [
# No optional arguments
("https://example.com", "https://example.com", 0),
# Each optional argument
("https://example.com -n", "https://example.com", 1),
("-n https://example.com", "https://example.com", 1),
("https://example.com -t", "https://example.com", 2),
("-t https://example.com", "https://example.com", 2),
# Long form
("https://example.com --new-window", "https://example.com", 1),
("--new-window https://example.com", "https://example.com", 1),
("https://example.com --new-tab", "https://example.com", 2),
("--new-tab https://example.com", "https://example.com", 2),
]:
args = webbrowser.parse_args(shlex.split(command))
self.assertEqual(args.url, url)
self.assertEqual(args.new_win, new_win)
def test_parse_args_error(self):
for command in [
# Arguments must not both be given
"https://example.com -n -t",
"https://example.com --new-window --new-tab",
"https://example.com -n --new-tab",
"https://example.com --new-window -t",
# Ensure ambiguous shortening fails
"https://example.com --new",
]:
with self.assertRaises(SystemExit):
webbrowser.parse_args(shlex.split(command))
def test_main(self):
for command, expected_url, expected_new_win in [
# No optional arguments
("https://example.com", "https://example.com", 0),
# Each optional argument
("https://example.com -n", "https://example.com", 1),
("-n https://example.com", "https://example.com", 1),
("https://example.com -t", "https://example.com", 2),
("-t https://example.com", "https://example.com", 2),
# Long form
("https://example.com --new-window", "https://example.com", 1),
("--new-window https://example.com", "https://example.com", 1),
("https://example.com --new-tab", "https://example.com", 2),
("--new-tab https://example.com", "https://example.com", 2),
]:
with (
mock.patch("webbrowser.open", return_value=None) as mock_open,
mock.patch("builtins.print", return_value=None),
):
webbrowser.main(shlex.split(command))
mock_open.assert_called_once_with(expected_url, expected_new_win)
if __name__ == '__main__':
unittest.main() unittest.main()

View file

@ -11,14 +11,17 @@ import threading
__all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"] __all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"]
class Error(Exception): class Error(Exception):
pass pass
_lock = threading.RLock() _lock = threading.RLock()
_browsers = {} # Dictionary of available browser controllers _browsers = {} # Dictionary of available browser controllers
_tryorder = None # Preference order of available browsers _tryorder = None # Preference order of available browsers
_os_preferred_browser = None # The preferred browser _os_preferred_browser = None # The preferred browser
def register(name, klass, instance=None, *, preferred=False): def register(name, klass, instance=None, *, preferred=False):
"""Register a browser connector.""" """Register a browser connector."""
with _lock: with _lock:
@ -34,6 +37,7 @@ def register(name, klass, instance=None, *, preferred=False):
else: else:
_tryorder.append(name) _tryorder.append(name)
def get(using=None): def get(using=None):
"""Return a browser launcher instance appropriate for the environment.""" """Return a browser launcher instance appropriate for the environment."""
if _tryorder is None: if _tryorder is None:
@ -64,6 +68,7 @@ def get(using=None):
return command[0]() return command[0]()
raise Error("could not locate runnable browser") raise Error("could not locate runnable browser")
# Please note: the following definition hides a builtin function. # Please note: the following definition hides a builtin function.
# It is recommended one does "import webbrowser" and uses webbrowser.open(url) # It is recommended one does "import webbrowser" and uses webbrowser.open(url)
# instead of "from webbrowser import *". # instead of "from webbrowser import *".
@ -87,6 +92,7 @@ def open(url, new=0, autoraise=True):
return True return True
return False return False
def open_new(url): def open_new(url):
"""Open url in a new window of the default browser. """Open url in a new window of the default browser.
@ -94,6 +100,7 @@ def open_new(url):
""" """
return open(url, 1) return open(url, 1)
def open_new_tab(url): def open_new_tab(url):
"""Open url in a new page ("tab") of the default browser. """Open url in a new page ("tab") of the default browser.
@ -136,7 +143,7 @@ def _synthesize(browser, *, preferred=False):
# General parent classes # General parent classes
class BaseBrowser(object): class BaseBrowser:
"""Parent class for all browsers. Do not use directly.""" """Parent class for all browsers. Do not use directly."""
args = ['%s'] args = ['%s']
@ -197,7 +204,7 @@ class BackgroundBrowser(GenericBrowser):
else: else:
p = subprocess.Popen(cmdline, close_fds=True, p = subprocess.Popen(cmdline, close_fds=True,
start_new_session=True) start_new_session=True)
return (p.poll() is None) return p.poll() is None
except OSError: except OSError:
return False return False
@ -225,7 +232,8 @@ class UnixBrowser(BaseBrowser):
# use autoraise argument only for remote invocation # use autoraise argument only for remote invocation
autoraise = int(autoraise) autoraise = int(autoraise)
opt = self.raise_opts[autoraise] opt = self.raise_opts[autoraise]
if opt: raise_opt = [opt] if opt:
raise_opt = [opt]
cmdline = [self.name] + raise_opt + args cmdline = [self.name] + raise_opt + args
@ -266,8 +274,8 @@ class UnixBrowser(BaseBrowser):
else: else:
action = self.remote_action_newtab action = self.remote_action_newtab
else: else:
raise Error("Bad 'new' parameter to open(); " + raise Error("Bad 'new' parameter to open(); "
"expected 0, 1, or 2, got %s" % new) f"expected 0, 1, or 2, got {new}")
args = [arg.replace("%s", url).replace("%action", action) args = [arg.replace("%s", url).replace("%action", action)
for arg in self.remote_args] for arg in self.remote_args]
@ -302,7 +310,7 @@ class Epiphany(UnixBrowser):
class Chrome(UnixBrowser): class Chrome(UnixBrowser):
"Launcher class for Google Chrome browser." """Launcher class for Google Chrome browser."""
remote_args = ['%action', '%s'] remote_args = ['%action', '%s']
remote_action = "" remote_action = ""
@ -310,11 +318,12 @@ class Chrome(UnixBrowser):
remote_action_newtab = "" remote_action_newtab = ""
background = True background = True
Chromium = Chrome Chromium = Chrome
class Opera(UnixBrowser): class Opera(UnixBrowser):
"Launcher class for Opera browser." """Launcher class for Opera browser."""
remote_args = ['%action', '%s'] remote_args = ['%action', '%s']
remote_action = "" remote_action = ""
@ -324,7 +333,7 @@ class Opera(UnixBrowser):
class Elinks(UnixBrowser): class Elinks(UnixBrowser):
"Launcher class for Elinks browsers." """Launcher class for Elinks browsers."""
remote_args = ['-remote', 'openURL(%s%action)'] remote_args = ['-remote', 'openURL(%s%action)']
remote_action = "" remote_action = ""
@ -387,11 +396,11 @@ class Konqueror(BaseBrowser):
except OSError: except OSError:
return False return False
else: else:
return (p.poll() is None) return p.poll() is None
class Edge(UnixBrowser): class Edge(UnixBrowser):
"Launcher class for Microsoft Edge browser." """Launcher class for Microsoft Edge browser."""
remote_args = ['%action', '%s'] remote_args = ['%action', '%s']
remote_action = "" remote_action = ""
@ -461,7 +470,6 @@ def register_X_browsers():
if shutil.which("opera"): if shutil.which("opera"):
register("opera", None, Opera("opera")) register("opera", None, Opera("opera"))
if shutil.which("microsoft-edge"): if shutil.which("microsoft-edge"):
register("microsoft-edge", None, Edge("microsoft-edge")) register("microsoft-edge", None, Edge("microsoft-edge"))
@ -514,7 +522,8 @@ def register_standard_browsers():
cmd = "xdg-settings get default-web-browser".split() cmd = "xdg-settings get default-web-browser".split()
raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
result = raw_result.decode().strip() result = raw_result.decode().strip()
except (FileNotFoundError, subprocess.CalledProcessError, PermissionError, NotADirectoryError) : except (FileNotFoundError, subprocess.CalledProcessError,
PermissionError, NotADirectoryError):
pass pass
else: else:
global _os_preferred_browser global _os_preferred_browser
@ -584,15 +593,16 @@ if sys.platform == 'darwin':
def open(self, url, new=0, autoraise=True): def open(self, url, new=0, autoraise=True):
sys.audit("webbrowser.open", url) sys.audit("webbrowser.open", url)
url = url.replace('"', '%22')
if self.name == 'default': if self.name == 'default':
script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser script = f'open location "{url}"' # opens in default browser
else: else:
script = f''' script = f'''
tell application "%s" tell application "{self.name}"
activate activate
open location "%s" open location "{url}"
end end
'''%(self.name, url.replace('"', '%22')) '''
osapipe = os.popen("osascript", "w") osapipe = os.popen("osascript", "w")
if osapipe is None: if osapipe is None:
@ -667,33 +677,31 @@ if sys.platform == "ios":
return True return True
def main(): def parse_args(arg_list: list[str] | None):
import getopt import argparse
usage = """Usage: %s [-n | -t | -h] url parser = argparse.ArgumentParser(description="Open URL in a web browser.")
-n: open new window parser.add_argument("url", help="URL to open")
-t: open new tab
-h, --help: show help""" % sys.argv[0]
try:
opts, args = getopt.getopt(sys.argv[1:], 'ntdh',['help'])
except getopt.error as msg:
print(msg, file=sys.stderr)
print(usage, file=sys.stderr)
sys.exit(1)
new_win = 0
for o, a in opts:
if o == '-n': new_win = 1
elif o == '-t': new_win = 2
elif o == '-h' or o == '--help':
print(usage, file=sys.stderr)
sys.exit()
if len(args) != 1:
print(usage, file=sys.stderr)
sys.exit(1)
url = args[0] group = parser.add_mutually_exclusive_group()
open(url, new_win) group.add_argument("-n", "--new-window", action="store_const",
const=1, default=0, dest="new_win",
help="open new window")
group.add_argument("-t", "--new-tab", action="store_const",
const=2, default=0, dest="new_win",
help="open new tab")
args = parser.parse_args(arg_list)
return args
def main(arg_list: list[str] | None = None):
args = parse_args(arg_list)
open(args.url, args.new_win)
print("\a") print("\a")
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View file

@ -0,0 +1,2 @@
webbrowser CLI: replace getopt with argparse, add long options. Patch by
Hugo van Kemenade.