Repository: python/cpython
Pull request #133440: gh-133439: Fix dot commands with trailing spaces are mistaken for multi-line sqlite statements in the sqlite3 command-line interface
Author: login: tanloong
Created: 2025-05-05T14:17:12Z | Merged: 2025-05-09T11:41:10Z

<!--
Thanks for your contribution!
Please read this comment in its entirety. It's quite important.

# Pull Request title

It should be in the following format:

```
gh-NNNNNN: Summary of the changes made
```

Where: gh-NNNNNN refers to the GitHub issue number.

Most PRs will require an issue number. Trivial changes, like fixing a typo, do not need an issue.

# Backport Pull Request title

If this is a backport PR (PR made against branches other than `main`),
please ensure that the PR title is in the following format:

```
[X.Y] <title from the original PR> (GH-NNNNNN)
```

Where: [X.Y] is the branch name, for example: [3.13].

GH-NNNNNN refers to the PR number from `main`.

-->
Issue: [sqlite3 CLI does not recognize dot commands with trailing spaces #133439](https://github.com/python/cpython/issues/133439)


<!-- gh-issue-number: gh-133439 -->
* Issue: gh-133439
<!-- /gh-issue-number -->



Diff:
diff --git a/Lib/sqlite3/__main__.py b/Lib/sqlite3/__main__.py
index 002f1986cddb89..4ccf292ddf211c 100644
--- a/Lib/sqlite3/__main__.py
+++ b/Lib/sqlite3/__main__.py
@@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
+                               f'  "{unknown}".\n')
+        else:
+            if not sqlite3.complete_statement(source):
+                return True
+            execute(self._cur, source)
         return False
 
 
diff --git a/Lib/test/test_sqlite3/test_cli.py b/Lib/test/test_sqlite3/test_cli.py
index ad0dcb3cccb5da..a03d7cbe16ba84 100644
--- a/Lib/test/test_sqlite3/test_cli.py
+++ b/Lib/test/test_sqlite3/test_cli.py
@@ -116,6 +116,38 @@ def test_interact_version(self):
         self.assertEqual(out.count(self.PS2), 0)
         self.assertIn(sqlite3.sqlite_version, out)
 
+    def test_interact_empty_source(self):
+        out, err = self.run_cli(commands=("", " "))
+        self.assertIn(self.MEMORY_DB_MSG, err)
+        self.assertEndsWith(out, self.PS1)
+        self.assertEqual(out.count(self.PS1), 3)
+        self.assertEqual(out.count(self.PS2), 0)
+
+    def test_interact_dot_commands_unknown(self):
+        out, err = self.run_cli(commands=(".unknown_command", ))
+        self.assertIn(self.MEMORY_DB_MSG, err)
+        self.assertEndsWith(out, self.PS1)
+        self.assertEqual(out.count(self.PS1), 2)
+        self.assertEqual(out.count(self.PS2), 0)
+        self.assertIn("Error", err)
+        # test "unknown_command" is pointed out in the error message
+        self.assertIn("unknown_command", err)
+
+    def test_interact_dot_commands_empty(self):
+        out, err = self.run_cli(commands=("."))
+        self.assertIn(self.MEMORY_DB_MSG, err)
+        self.assertEndsWith(out, self.PS1)
+        self.assertEqual(out.count(self.PS1), 2)
+        self.assertEqual(out.count(self.PS2), 0)
+
+    def test_interact_dot_commands_with_whitespaces(self):
+        out, err = self.run_cli(commands=(".version ", ". version"))
+        self.assertIn(self.MEMORY_DB_MSG, err)
+        self.assertEqual(out.count(sqlite3.sqlite_version + "\n"), 2)
+        self.assertEndsWith(out, self.PS1)
+        self.assertEqual(out.count(self.PS1), 3)
+        self.assertEqual(out.count(self.PS2), 0)
+
     def test_interact_valid_sql(self):
         out, err = self.run_cli(commands=("SELECT 1;",))
         self.assertIn(self.MEMORY_DB_MSG, err)
diff --git a/Misc/NEWS.d/next/Library/2025-05-05-22-11-24.gh-issue-133439.LpmyFz.rst b/Misc/NEWS.d/next/Library/2025-05-05-22-11-24.gh-issue-133439.LpmyFz.rst
new file mode 100644
index 00000000000000..e0a3ce98bf7158
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-05-05-22-11-24.gh-issue-133439.LpmyFz.rst
@@ -0,0 +1,2 @@
+Fix dot commands with trailing spaces are mistaken for multi-line SQL
+statements in the sqlite3 command-line interface.


Reviews:
user: login: serhiy-storchaka
body: The sqlite3 CLI supports also space after dot:
```
sqlite> . version
SQLite 3.45.1 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1
zlib version 1.3
gcc-13.3.0 (64-bit)
```
But not before dot:
```
sqlite>  .version
   ...> 
```
To be conforming with it, we should check if the first character is a dot, and if it is, use `source[1:].strip()` as the command. No stripping is needed for the SQL statement.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T06:59:39Z
user: login: serhiy-storchaka
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T09:12:28Z
user: login: serhiy-storchaka
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-09T10:51:45Z
user: login: serhiy-storchaka
body: LGTM. :+1:
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-09T11:08:29Z
user: login: erlend-aasland
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T07:16:07Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T07:18:28Z
user: login: erlend-aasland
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T07:34:19Z
user: login: picnixz
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T07:36:14Z
user: login: StanFromIreland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T08:03:27Z
user: login: StanFromIreland
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T08:04:38Z

Review comments:
diff_hunk: @@ -48,17 +48,18 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if source[0] == ".":
path: Lib/sqlite3/__main__.py
user: login: serhiy-storchaka
body: Can source be empty string?
created_at: 2025-05-09T09:05:01Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,18 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if source[0] == ".":
+            match source[1:].strip():
path: Lib/sqlite3/__main__.py
user: login: serhiy-storchaka
body: What if the command starts with a dot, but does not match one of the following?

The sqlite3 CLI produces error:
```
sqlite> .spam
Error: unknown command or invalid arguments:  "spam". Enter ".help" for help
```
created_at: 2025-05-09T09:06:59Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,2 @@
+fix dot commands with trailing spaces are mistaken for multi-line SQL
path: Misc/NEWS.d/next/Library/2025-05-05-22-11-24.gh-issue-133439.LpmyFz.rst
user: login: serhiy-storchaka
body: Sentence should start with an upper case and end with a period. 
created_at: 2025-05-09T09:08:53Z
author_association: MEMBER
diff_hunk: @@ -59,8 +59,9 @@ def runsource(self, source, filename="<input>", symbol="single"):
                 case "quit":
                     sys.exit(0)
                 case _ as unknown:
-                    print("Error: unknown command or invalid arguments:  "
-                          f'"{unknown}". Enter ".help" for help')
+                    if unknown:
path: Lib/sqlite3/__main__.py
user: login: serhiy-storchaka
body: You can simply add
```py
case "":
    pass
```
created_at: 2025-05-09T10:35:43Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,24 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case _ as unknown:
+                    if unknown:
+                        print("Error: unknown command or invalid arguments:"
+                              f'  "{unknown}". Enter ".help" for help')
path: Lib/sqlite3/__main__.py
user: login: serhiy-storchaka
body: Entering ".help" does not help, because it does not output the list of commands.
created_at: 2025-05-09T10:47:40Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,24 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case _ as unknown:
+                    if unknown:
+                        print("Error: unknown command or invalid arguments:"
path: Lib/sqlite3/__main__.py
user: login: serhiy-storchaka
body: Errors should be printed to stderr. I think you can use `self.write()` for this.
created_at: 2025-05-09T10:50:09Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
path: Lib/sqlite3/__main__.py
user: login: erlend-aasland
body: Why `self.write`?
created_at: 2025-05-10T07:16:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
path: Lib/sqlite3/__main__.py
user: login: picnixz
body: I think it's to write onto `sys.stderr`. This uses `InteractiveInterpreter.write` here.
created_at: 2025-05-10T07:18:27Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
path: Lib/sqlite3/__main__.py
user: login: erlend-aasland
body: Right, `InteractiveInterpreter.write` defaults to `stderr`. That makes sense!
created_at: 2025-05-10T07:34:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
path: Lib/sqlite3/__main__.py
user: login: picnixz
body: (I was also confused and needed to look up the code to see why)
created_at: 2025-05-10T07:36:14Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
path: Lib/sqlite3/__main__.py
user: login: StanFromIreland
body: I made it more consistent and simpler in: https://github.com/python/cpython/pull/133807/
created_at: 2025-05-10T08:03:27Z
author_association: MEMBER
diff_hunk: @@ -48,17 +48,25 @@ def runsource(self, source, filename="<input>", symbol="single"):
         Return True if more input is needed; buffering is done automatically.
         Return False if input is a complete statement ready for execution.
         """
-        match source:
-            case ".version":
-                print(f"{sqlite3.sqlite_version}")
-            case ".help":
-                print("Enter SQL code and press enter.")
-            case ".quit":
-                sys.exit(0)
-            case _:
-                if not sqlite3.complete_statement(source):
-                    return True
-                execute(self._cur, source)
+        if not source or source.isspace():
+            return False
+        if source[0] == ".":
+            match source[1:].strip():
+                case "version":
+                    print(f"{sqlite3.sqlite_version}")
+                case "help":
+                    print("Enter SQL code and press enter.")
+                case "quit":
+                    sys.exit(0)
+                case "":
+                    pass
+                case _ as unknown:
+                    self.write("Error: unknown command or invalid arguments:"
+                               f'  "{unknown}".\n')
path: Lib/sqlite3/__main__.py
user: login: StanFromIreland
body: Error messages do not end with a '.' e.g.:

```
Python 3.15.0a0 (heads/main:c81fa2b9cd1, May  8 2025, 16:38:25) [GCC 15.1.1 20250425 (Red Hat 15.1.1-1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> test
Traceback (most recent call last):
  File "<python-input-0>", line 1, in <module>
    test
NameError: name 'test' is not defined
```
created_at: 2025-05-10T08:04:38Z
author_association: MEMBER

PR comments:
user: login: tanloong
created_at: 2025-05-09T08:21:10Z
body: Thank you for your review! I have modified the code to use `source[1:].strip()` as dot command, no stripping for SQL statement.
author_association: CONTRIBUTOR
user: login: tanloong
created_at: 2025-05-09T09:55:02Z
body: Sorry for the overlook, now the empty source and unknown dot commands are handled. I have improved the news entry.
author_association: CONTRIBUTOR
user: login: miss-islington-app[bot]
created_at: 2025-05-09T11:41:13Z
body: Thanks @tanloong for the PR, and @serhiy-storchaka for merging it 🌮🎉.. I'm working now to backport this PR to: 3.14.
🐍🍒⛏🤖 
author_association: NONE
user: login: bedevere-app[bot]
created_at: 2025-05-09T11:41:25Z
body: [GH-133738](https://github.com/python/cpython/pull/133738) is a backport of this pull request to the [3.14 branch](https://github.com/python/cpython/tree/3.14).
author_association: NONE
user: login: picnixz
created_at: 2025-05-09T11:44:37Z
body: @serhiy-storchaka no 3.13 backports?
author_association: MEMBER
user: login: serhiy-storchaka
created_at: 2025-05-09T14:27:29Z
body: This is borderline between minor bug fix and minor feature. It improves the quality of life a little, but most people won't notice anything. If anyone wants to backport to 3.13, please do. If not, no problem either.
author_association: MEMBER
user: login: tanloong
created_at: 2025-05-09T14:42:01Z
body: I will do that.
author_association: CONTRIBUTOR
user: login: bedevere-app[bot]
created_at: 2025-05-09T14:44:09Z
body: [GH-133765](https://github.com/python/cpython/pull/133765) is a backport of this pull request to the [3.13 branch](https://github.com/python/cpython/tree/3.13).
author_association: NONE
user: login: serhiy-storchaka
created_at: 2025-05-09T14:45:36Z
body: @tanloong, I meant any of the core developers. It's as simple as adding a label. Someone just has to want to do it.
author_association: MEMBER
user: login: tanloong
created_at: 2025-05-09T14:49:58Z
body: Ah, sorry, I misunderstood.
author_association: CONTRIBUTOR