Repository: python/cpython (Python)

Issue:
`pdb` CLI doesn't handle incorrect arguments properly
# Bug report

### Checklist

- [X] I am confident this is a bug in CPython, not a bug in a third-party project
- [X] I have searched the [CPython issue tracker](https://github.com/python/cpython/issues?q=is%3Aissue+sort%3Acreated-desc),
and am confident this bug has not been reported before


### CPython versions tested on:

3.11, 3.12, CPython main branch

### Operating systems tested on:

Linux

### Output from running 'python -VV' on the command line:

Python 3.13.0a0 (heads/main:578ebc5d5f, Sep  1 2023, 20:48:35) [GCC 10.2.1 20210110]

### A clear and concise description of the bug:

`pdb` module produces large traceback instead of short error message if invoked with invalid command line option. This happens because it doesn't handle exceptions that can occur in `getopt.getopt`, as it typically done.
```
$ ./python -m pdb -c
Traceback (most recent call last):
  File "/home/radislav/projects/cpython/Lib/runpy.py", line 198, in _run_module_as_main
    return _run_code(code, main_globals, None,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/runpy.py", line 88, in _run_code
    exec(code, run_globals)
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 2114, in <module>
    pdb.main()
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 2060, in main
    opts, args = getopt.getopt(sys.argv[1:], 'mhc:', ['help', 'command='])
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/getopt.py", line 95, in getopt
    opts, args = do_shorts(opts, args[0][1:], shortopts, args[1:])
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/getopt.py", line 198, in do_shorts
    raise GetoptError(_('option -%s requires argument') % opt,
getopt.GetoptError: option -c requires argument
```
A similar situation is with nonexistant modules and directory names. In the first case an exception that occurs in `_ModuleTarget.check` is printed to stderr with its traceback. In the second case directory name is 'successfully' checked by `_ScriptTarget.check` call, and debugger is ran on invalid target.
```
$ ./python -m pdb -m spam
Traceback (most recent call last):
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 166, in check
    self._details
  File "/home/radislav/projects/cpython/Lib/functools.py", line 1014, in __get__
    val = self.func(instance)
          ^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 174, in _details
    return runpy._get_module_details(self)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/runpy.py", line 142, in _get_module_details
    raise error("No module named %s" % mod_name)
ImportError: No module named spam
```
```
$ ./python -m pdb /
Traceback (most recent call last):
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 2088, in main
    pdb._run(target)
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 1868, in _run
    self.run(target.code)
             ^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 159, in code
    with io.open_code(self) as fp:
         ^^^^^^^^^^^^^^^^^^
IsADirectoryError: [Errno 21] Is a directory: '/'
Uncaught exception. Entering post mortem debugging
Running 'cont' or 'step' will restart the program
> /home/radislav/projects/cpython/Lib/pdb.py(159)code()
-> with io.open_code(self) as fp:
(Pdb) c
Traceback (most recent call last):
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 2088, in main
    pdb._run(target)
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 1868, in _run
    self.run(target.code)
             ^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 159, in code
    with io.open_code(self) as fp:
         ^^^^^^^^^^^^^^^^^^
IsADirectoryError: [Errno 21] Is a directory: '/'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/radislav/projects/cpython/Lib/runpy.py", line 198, in _run_module_as_main
    return _run_code(code, main_globals, None,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/runpy.py", line 88, in _run_code
    exec(code, run_globals)
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 2114, in <module>
    pdb.main()
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 2106, in main
    pdb.interaction(None, e)
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 501, in interaction
    self._cmdloop()
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 405, in _cmdloop
    self.cmdloop()
  File "/home/radislav/projects/cpython/Lib/cmd.py", line 138, in cmdloop
    stop = self.onecmd(line)
           ^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 592, in onecmd
    return cmd.Cmd.onecmd(self, line)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/cmd.py", line 217, in onecmd
    return func(arg)
           ^^^^^^^^^
  File "/home/radislav/projects/cpython/Lib/pdb.py", line 1329, in do_continue
    self.set_continue()
  File "/home/radislav/projects/cpython/Lib/bdb.py", line 344, in set_continue
    self._set_stopinfo(self.botframe, None, -1)
                       ^^^^^^^^^^^^^
AttributeError: 'Pdb' object has no attribute 'botframe'. Did you mean: 'curframe'?
```
I'm working on a fix.

<!-- gh-linked-prs -->
### Linked PRs
* gh-108816
* gh-110915
* gh-110916
* gh-111063
* gh-111064
<!-- /gh-linked-prs -->



Discussion:

Patch:
diff --git a/Lib/pdb.py b/Lib/pdb.py
index 2cbd6f6746773f..38a6a501d08698 100755
--- a/Lib/pdb.py
+++ b/Lib/pdb.py
@@ -138,6 +138,9 @@ def check(self):
         if not os.path.exists(self):
             print('Error:', self.orig, 'does not exist')
             sys.exit(1)
+        if os.path.isdir(self):
+            print('Error:', self.orig, 'is a directory')
+            sys.exit(1)
 
         # Replace pdb's dir with script's dir in front of module search path.
         sys.path[0] = os.path.dirname(self)
@@ -164,6 +167,9 @@ class _ModuleTarget(str):
     def check(self):
         try:
             self._details
+        except ImportError as e:
+            print(f"ImportError: {e}")
+            sys.exit(1)
         except Exception:
             traceback.print_exc()
             sys.exit(1)
diff --git a/Misc/NEWS.d/next/Library/2023-09-02-16-07-23.gh-issue-108791.fBcAqh.rst b/Misc/NEWS.d/next/Library/2023-09-02-16-07-23.gh-issue-108791.fBcAqh.rst
new file mode 100644
index 00000000000000..84a2cd589e10d5
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2023-09-02-16-07-23.gh-issue-108791.fBcAqh.rst
@@ -0,0 +1,1 @@
+Improved error handling in :mod:`pdb` command line interface, making it produce more concise error messages.


Test patch:
diff --git a/Lib/test/test_pdb.py b/Lib/test/test_pdb.py
index b1652e993b8a01..705cb5274375f6 100644
--- a/Lib/test/test_pdb.py
+++ b/Lib/test/test_pdb.py
@@ -2758,8 +2758,7 @@ def test_module_without_a_main(self):
         stdout, stderr = self._run_pdb(
             ['-m', module_name], "", expected_returncode=1
         )
-        self.assertIn("ImportError: No module named t_main.__main__",
-                      stdout.splitlines())
+        self.assertIn("ImportError: No module named t_main.__main__;", stdout)
 
     def test_package_without_a_main(self):
         pkg_name = 't_pkg'
@@ -2777,6 +2776,22 @@ def test_package_without_a_main(self):
             "'t_pkg.t_main' is a package and cannot be directly executed",
             stdout)
 
+    def test_nonexistent_module(self):
+        assert not os.path.exists(os_helper.TESTFN)
+        stdout, stderr = self._run_pdb(["-m", os_helper.TESTFN], "", expected_returncode=1)
+        self.assertIn(f"ImportError: No module named {os_helper.TESTFN}", stdout)
+
+    def test_dir_as_script(self):
+        with os_helper.temp_dir() as temp_dir:
+            stdout, stderr = self._run_pdb([temp_dir], "", expected_returncode=1)
+            self.assertIn(f"Error: {temp_dir} is a directory", stdout)
+
+    def test_invalid_cmd_line_options(self):
+        stdout, stderr = self._run_pdb(["-c"], "", expected_returncode=2)
+        self.assertIn(f"pdb: error: argument -c/--command: expected one argument", stdout.split('\n')[1])
+        stdout, stderr = self._run_pdb(["--spam", "-m", "pdb"], "", expected_returncode=2)
+        self.assertIn(f"pdb: error: unrecognized arguments: --spam", stdout.split('\n')[1])
+
     def test_blocks_at_first_code_line(self):
         script = """
                 #This is a comment, on line 2