Skip to content

Commit 56ed979

Browse files
authored
gh-68583: webbrowser: replace getopt with argparse, add long options (#117047)
1 parent 022ba6d commit 56ed979

File tree

4 files changed

+135
-55
lines changed

4 files changed

+135
-55
lines changed

Doc/library/webbrowser.rst

+6-3
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,12 @@ a new tab, with the browser being brought to the foreground. The use of the
4242

4343
The script :program:`webbrowser` can be used as a command-line interface for the
4444
module. It accepts a URL as the argument. It accepts the following optional
45-
parameters: ``-n`` opens the URL in a new browser window, if possible;
46-
``-t`` opens the URL in a new browser page ("tab"). The options are,
47-
naturally, mutually exclusive. Usage example::
45+
parameters:
46+
47+
* ``-n``/``--new-window`` opens the URL in a new browser window, if possible.
48+
* ``-t``/``--new-tab`` opens the URL in a new browser page ("tab").
49+
50+
The options are, naturally, mutually exclusive. Usage example::
4851

4952
python -m webbrowser -t "https://www.python.org"
5053

Lib/test/test_webbrowser.py

+78-11
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
import webbrowser
2-
import unittest
31
import os
4-
import sys
2+
import re
3+
import shlex
54
import subprocess
6-
from unittest import mock
5+
import sys
6+
import unittest
7+
import webbrowser
78
from test import support
8-
from test.support import is_apple_mobile
99
from test.support import import_helper
10+
from test.support import is_apple_mobile
1011
from test.support import os_helper
1112
from test.support import requires_subprocess
1213
from test.support import threading_helper
14+
from unittest import mock
1315

1416
# The webbrowser module uses threading locks
1517
threading_helper.requires_working_threading(module=True)
@@ -98,6 +100,15 @@ def test_open_new_tab(self):
98100
options=[],
99101
arguments=[URL])
100102

103+
def test_open_bad_new_parameter(self):
104+
with self.assertRaisesRegex(webbrowser.Error,
105+
re.escape("Bad 'new' parameter to open(); "
106+
"expected 0, 1, or 2, got 999")):
107+
self._test('open',
108+
options=[],
109+
arguments=[URL],
110+
kw=dict(new=999))
111+
101112

102113
class EdgeCommandTest(CommandTestMixin, unittest.TestCase):
103114

@@ -205,22 +216,22 @@ class ELinksCommandTest(CommandTestMixin, unittest.TestCase):
205216

206217
def test_open(self):
207218
self._test('open', options=['-remote'],
208-
arguments=['openURL({})'.format(URL)])
219+
arguments=[f'openURL({URL})'])
209220

210221
def test_open_with_autoraise_false(self):
211222
self._test('open',
212223
options=['-remote'],
213-
arguments=['openURL({})'.format(URL)])
224+
arguments=[f'openURL({URL})'])
214225

215226
def test_open_new(self):
216227
self._test('open_new',
217228
options=['-remote'],
218-
arguments=['openURL({},new-window)'.format(URL)])
229+
arguments=[f'openURL({URL},new-window)'])
219230

220231
def test_open_new_tab(self):
221232
self._test('open_new_tab',
222233
options=['-remote'],
223-
arguments=['openURL({},new-tab)'.format(URL)])
234+
arguments=[f'openURL({URL},new-tab)'])
224235

225236

226237
@unittest.skipUnless(sys.platform == "ios", "Test only applicable to iOS")
@@ -342,7 +353,6 @@ def test_register_default(self):
342353
def test_register_preferred(self):
343354
self._check_registration(preferred=True)
344355

345-
346356
@unittest.skipUnless(sys.platform == "darwin", "macOS specific test")
347357
def test_no_xdg_settings_on_macOS(self):
348358
# On macOS webbrowser should not use xdg-settings to
@@ -423,5 +433,62 @@ def test_environment_preferred(self):
423433
self.assertEqual(webbrowser.get().name, sys.executable)
424434

425435

426-
if __name__=='__main__':
436+
class CliTest(unittest.TestCase):
437+
def test_parse_args(self):
438+
for command, url, new_win in [
439+
# No optional arguments
440+
("https://example.com", "https://example.com", 0),
441+
# Each optional argument
442+
("https://example.com -n", "https://example.com", 1),
443+
("-n https://example.com", "https://example.com", 1),
444+
("https://example.com -t", "https://example.com", 2),
445+
("-t https://example.com", "https://example.com", 2),
446+
# Long form
447+
("https://example.com --new-window", "https://example.com", 1),
448+
("--new-window https://example.com", "https://example.com", 1),
449+
("https://example.com --new-tab", "https://example.com", 2),
450+
("--new-tab https://example.com", "https://example.com", 2),
451+
]:
452+
args = webbrowser.parse_args(shlex.split(command))
453+
454+
self.assertEqual(args.url, url)
455+
self.assertEqual(args.new_win, new_win)
456+
457+
def test_parse_args_error(self):
458+
for command in [
459+
# Arguments must not both be given
460+
"https://example.com -n -t",
461+
"https://example.com --new-window --new-tab",
462+
"https://example.com -n --new-tab",
463+
"https://example.com --new-window -t",
464+
# Ensure ambiguous shortening fails
465+
"https://example.com --new",
466+
]:
467+
with self.assertRaises(SystemExit):
468+
webbrowser.parse_args(shlex.split(command))
469+
470+
def test_main(self):
471+
for command, expected_url, expected_new_win in [
472+
# No optional arguments
473+
("https://example.com", "https://example.com", 0),
474+
# Each optional argument
475+
("https://example.com -n", "https://example.com", 1),
476+
("-n https://example.com", "https://example.com", 1),
477+
("https://example.com -t", "https://example.com", 2),
478+
("-t https://example.com", "https://example.com", 2),
479+
# Long form
480+
("https://example.com --new-window", "https://example.com", 1),
481+
("--new-window https://example.com", "https://example.com", 1),
482+
("https://example.com --new-tab", "https://example.com", 2),
483+
("--new-tab https://example.com", "https://example.com", 2),
484+
]:
485+
with (
486+
mock.patch("webbrowser.open", return_value=None) as mock_open,
487+
mock.patch("builtins.print", return_value=None),
488+
):
489+
webbrowser.main(shlex.split(command))
490+
mock_open.assert_called_once_with(expected_url, expected_new_win)
491+
492+
493+
if __name__ == '__main__':
427494
unittest.main()

Lib/webbrowser.py

+49-41
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@
1111

1212
__all__ = ["Error", "open", "open_new", "open_new_tab", "get", "register"]
1313

14+
1415
class Error(Exception):
1516
pass
1617

18+
1719
_lock = threading.RLock()
1820
_browsers = {} # Dictionary of available browser controllers
1921
_tryorder = None # Preference order of available browsers
2022
_os_preferred_browser = None # The preferred browser
2123

24+
2225
def register(name, klass, instance=None, *, preferred=False):
2326
"""Register a browser connector."""
2427
with _lock:
@@ -34,6 +37,7 @@ def register(name, klass, instance=None, *, preferred=False):
3437
else:
3538
_tryorder.append(name)
3639

40+
3741
def get(using=None):
3842
"""Return a browser launcher instance appropriate for the environment."""
3943
if _tryorder is None:
@@ -64,6 +68,7 @@ def get(using=None):
6468
return command[0]()
6569
raise Error("could not locate runnable browser")
6670

71+
6772
# Please note: the following definition hides a builtin function.
6873
# It is recommended one does "import webbrowser" and uses webbrowser.open(url)
6974
# instead of "from webbrowser import *".
@@ -87,13 +92,15 @@ def open(url, new=0, autoraise=True):
8792
return True
8893
return False
8994

95+
9096
def open_new(url):
9197
"""Open url in a new window of the default browser.
9298
9399
If not possible, then open url in the only browser window.
94100
"""
95101
return open(url, 1)
96102

103+
97104
def open_new_tab(url):
98105
"""Open url in a new page ("tab") of the default browser.
99106
@@ -136,7 +143,7 @@ def _synthesize(browser, *, preferred=False):
136143

137144
# General parent classes
138145

139-
class BaseBrowser(object):
146+
class BaseBrowser:
140147
"""Parent class for all browsers. Do not use directly."""
141148

142149
args = ['%s']
@@ -197,7 +204,7 @@ def open(self, url, new=0, autoraise=True):
197204
else:
198205
p = subprocess.Popen(cmdline, close_fds=True,
199206
start_new_session=True)
200-
return (p.poll() is None)
207+
return p.poll() is None
201208
except OSError:
202209
return False
203210

@@ -225,7 +232,8 @@ def _invoke(self, args, remote, autoraise, url=None):
225232
# use autoraise argument only for remote invocation
226233
autoraise = int(autoraise)
227234
opt = self.raise_opts[autoraise]
228-
if opt: raise_opt = [opt]
235+
if opt:
236+
raise_opt = [opt]
229237

230238
cmdline = [self.name] + raise_opt + args
231239

@@ -266,8 +274,8 @@ def open(self, url, new=0, autoraise=True):
266274
else:
267275
action = self.remote_action_newtab
268276
else:
269-
raise Error("Bad 'new' parameter to open(); " +
270-
"expected 0, 1, or 2, got %s" % new)
277+
raise Error("Bad 'new' parameter to open(); "
278+
f"expected 0, 1, or 2, got {new}")
271279

272280
args = [arg.replace("%s", url).replace("%action", action)
273281
for arg in self.remote_args]
@@ -302,19 +310,20 @@ class Epiphany(UnixBrowser):
302310

303311

304312
class Chrome(UnixBrowser):
305-
"Launcher class for Google Chrome browser."
313+
"""Launcher class for Google Chrome browser."""
306314

307315
remote_args = ['%action', '%s']
308316
remote_action = ""
309317
remote_action_newwin = "--new-window"
310318
remote_action_newtab = ""
311319
background = True
312320

321+
313322
Chromium = Chrome
314323

315324

316325
class Opera(UnixBrowser):
317-
"Launcher class for Opera browser."
326+
"""Launcher class for Opera browser."""
318327

319328
remote_args = ['%action', '%s']
320329
remote_action = ""
@@ -324,7 +333,7 @@ class Opera(UnixBrowser):
324333

325334

326335
class Elinks(UnixBrowser):
327-
"Launcher class for Elinks browsers."
336+
"""Launcher class for Elinks browsers."""
328337

329338
remote_args = ['-remote', 'openURL(%s%action)']
330339
remote_action = ""
@@ -387,11 +396,11 @@ def open(self, url, new=0, autoraise=True):
387396
except OSError:
388397
return False
389398
else:
390-
return (p.poll() is None)
399+
return p.poll() is None
391400

392401

393402
class Edge(UnixBrowser):
394-
"Launcher class for Microsoft Edge browser."
403+
"""Launcher class for Microsoft Edge browser."""
395404

396405
remote_args = ['%action', '%s']
397406
remote_action = ""
@@ -461,7 +470,6 @@ def register_X_browsers():
461470
if shutil.which("opera"):
462471
register("opera", None, Opera("opera"))
463472

464-
465473
if shutil.which("microsoft-edge"):
466474
register("microsoft-edge", None, Edge("microsoft-edge"))
467475

@@ -514,7 +522,8 @@ def register_standard_browsers():
514522
cmd = "xdg-settings get default-web-browser".split()
515523
raw_result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
516524
result = raw_result.decode().strip()
517-
except (FileNotFoundError, subprocess.CalledProcessError, PermissionError, NotADirectoryError) :
525+
except (FileNotFoundError, subprocess.CalledProcessError,
526+
PermissionError, NotADirectoryError):
518527
pass
519528
else:
520529
global _os_preferred_browser
@@ -584,15 +593,16 @@ def __init__(self, name='default'):
584593

585594
def open(self, url, new=0, autoraise=True):
586595
sys.audit("webbrowser.open", url)
596+
url = url.replace('"', '%22')
587597
if self.name == 'default':
588-
script = 'open location "%s"' % url.replace('"', '%22') # opens in default browser
598+
script = f'open location "{url}"' # opens in default browser
589599
else:
590600
script = f'''
591-
tell application "%s"
601+
tell application "{self.name}"
592602
activate
593-
open location "%s"
603+
open location "{url}"
594604
end
595-
'''%(self.name, url.replace('"', '%22'))
605+
'''
596606

597607
osapipe = os.popen("osascript", "w")
598608
if osapipe is None:
@@ -667,33 +677,31 @@ def open(self, url, new=0, autoraise=True):
667677
return True
668678

669679

670-
def main():
671-
import getopt
672-
usage = """Usage: %s [-n | -t | -h] url
673-
-n: open new window
674-
-t: open new tab
675-
-h, --help: show help""" % sys.argv[0]
676-
try:
677-
opts, args = getopt.getopt(sys.argv[1:], 'ntdh',['help'])
678-
except getopt.error as msg:
679-
print(msg, file=sys.stderr)
680-
print(usage, file=sys.stderr)
681-
sys.exit(1)
682-
new_win = 0
683-
for o, a in opts:
684-
if o == '-n': new_win = 1
685-
elif o == '-t': new_win = 2
686-
elif o == '-h' or o == '--help':
687-
print(usage, file=sys.stderr)
688-
sys.exit()
689-
if len(args) != 1:
690-
print(usage, file=sys.stderr)
691-
sys.exit(1)
692-
693-
url = args[0]
694-
open(url, new_win)
680+
def parse_args(arg_list: list[str] | None):
681+
import argparse
682+
parser = argparse.ArgumentParser(description="Open URL in a web browser.")
683+
parser.add_argument("url", help="URL to open")
684+
685+
group = parser.add_mutually_exclusive_group()
686+
group.add_argument("-n", "--new-window", action="store_const",
687+
const=1, default=0, dest="new_win",
688+
help="open new window")
689+
group.add_argument("-t", "--new-tab", action="store_const",
690+
const=2, default=0, dest="new_win",
691+
help="open new tab")
692+
693+
args = parser.parse_args(arg_list)
694+
695+
return args
696+
697+
698+
def main(arg_list: list[str] | None = None):
699+
args = parse_args(arg_list)
700+
701+
open(args.url, args.new_win)
695702

696703
print("\a")
697704

705+
698706
if __name__ == "__main__":
699707
main()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
webbrowser CLI: replace getopt with argparse, add long options. Patch by
2+
Hugo van Kemenade.

0 commit comments

Comments
 (0)