Skip to content

gh-133390: Support basic completion for sqlite3 command-line interface #133393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from

Conversation

tanloong
Copy link
Contributor

@tanloong tanloong commented May 4, 2025

This adds tab-completion for the 147 SQLite Keywords. A whitespace is appended to completion candidates for users' convenience and to mimic the behavior of sqlite3 tool.

@python-cla-bot
Copy link

python-cla-bot bot commented May 4, 2025

All commit authors signed the Contributor License Agreement.

CLA signed

@bedevere-app
Copy link

bedevere-app bot commented May 4, 2025

Most changes to Python require a NEWS entry. Add one using the blurb_it web app or the blurb command-line tool.

If this change has little impact on Python users, wait for a maintainer to apply the skip news label instead.

Copy link
Contributor

@StanFromIreland StanFromIreland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a whatsnew entry, (maybe a note in the docs?), a NEWS entry and tests.

This is quite limited completion so I guess it could work.

@picnixz
Copy link
Member

picnixz commented May 4, 2025

I think we need a separate nodule for this because if we want to have a smarter completion later the code is better to be isolated, so something like sqlite3._completer.

If possible, can we have an autogenerated list of keywords? and those keywords could also be stored in a (private) module-level list.

@picnixz
Copy link
Member

picnixz commented May 4, 2025

Please add tests

@tanloong
Copy link
Contributor Author

tanloong commented May 4, 2025

Yes, tests is coming. I will try to find a way to autogenerate list of keywords.

@StanFromIreland
Copy link
Contributor

You can mark the pr as a draft.

@tanloong tanloong marked this pull request as draft May 4, 2025 21:23
@tanloong tanloong force-pushed the sqlite3-cli-completion branch 8 times, most recently from 782a599 to 5005d85 Compare May 5, 2025 04:47
@tanloong
Copy link
Contributor Author

tanloong commented May 5, 2025

Added tests and moved keyword list to module level.

Keywords seems not easy to auto-generate, the SQLite document says they can be accessed by sqlite3_keyword_count() and sqlite3_keyword_name() using C, but I am bad at C and didn't figure out how to call these two at Python level. I think these two functions are not exposed to Python?

@tanloong tanloong marked this pull request as ready for review May 5, 2025 05:08
Copy link
Contributor

@StanFromIreland StanFromIreland left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With #133447 in mind the keywords list would be quite handy (I think hardcoding is fine, it doesn't change often anyway). Maybe it could be moved elsewhere, what do you think Benedikt?

@tanloong tanloong force-pushed the sqlite3-cli-completion branch from 15019ea to 22652ae Compare May 5, 2025 18:20
@tanloong
Copy link
Contributor Author

tanloong commented May 5, 2025

Hi @picnixz, as Stan mentioned, beta freeze is around the corner, but I really hope this can be shipped in 3.14, do you think there is a chance to merge it before beta freeze?

@picnixz picnixz removed request for hugovk and ezio-melotti May 10, 2025 14:45
@picnixz
Copy link
Member

picnixz commented May 10, 2025

Please, do not force push. See https://devguide.python.org/getting-started/pull-request-lifecycle/.

from contextlib import contextmanager


_keywords = ("ABORT", "ACTION", "ADD", "AFTER", "ALL", "ALTER", "ALWAYS",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're anyway in a private module, so name this variable KEYWORDS. Ideally, I really want it coming from C but we can do it in a follow-up PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved KEYWORDS to C in the latest commit. I am not confident with my C code and look forward to your review. Thank you very much!

script = "from sqlite3.__main__ import main; main()"
input = b"zzzz\t;\n.quit\n"
output = run_pty(script, input, env={"NO_COLOR": "1"})
output = re.sub(rb"\x1b\[[0-9;]*[mK]", b"", output)
Copy link
Member

@picnixz picnixz May 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's find a way to avoid this. If this is a bug in run_pty, we should fix it (do we have some FORCE_NO_COLOR envvar perhaps?)

cc @hugovk as the colorization expert

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I figure it out. run_pty() has no bug. The problem is with GNU Readline, which needs special settings to turn off color.

Using env={"NO_COLOR":"1"} in subprocess does work, but it only stops coloring within what Python can control, like the sqlite> prompt. To disable color in GNU Readline itself, we need to run readline.parse_and_bind("set colored-completion-prefix off").

Copy link
Member

@picnixz picnixz May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh interesting. Maybe that's something to consider when we add colors?

cc @hugovk

@tanloong
Copy link
Contributor Author

I have pushed the update.

Please, do not force push. See https://devguide.python.org/getting-started/pull-request-lifecycle/.

Sorry, I will pay attention to this from now on.

def setUpClass(cls):
readline = import_module("readline")
if readline.backend == "editline":
raise unittest.SkipTest("libedit readline is not supported")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
raise unittest.SkipTest("libedit readline is not supported")
self.unittest.SkipTest("libedit readline is not supported")

Copy link
Contributor Author

@tanloong tanloong May 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean unittest.TestCase.skipTest(). There are many uses of it in the codebase, but there are also quite a few uses of raise unittest.SkipTest(), for example in test_pdb.py#L4759 which is used under exactly the same occasion (I actually copied that block from pdb to here). I think raising is OK?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's incorrect to use self.skipTest. We're in a class method so we can only raise the exception. skipTest is a regular method, not a class method.

raise unittest.SkipTest("libedit readline is not supported")

def test_keyword_completion(self):
script = "from sqlite3.__main__ import main; main()"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just use run_cli from the interactiveconsole tests above?

Copy link
Contributor Author

@tanloong tanloong May 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With run_cli() both the output and err contain no completion candidates. I failed with run_cli() so I switched to run_pty() when writing the tests.
图片

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then where are the completion candidates sent? The commands should be executed.

@StanFromIreland
Copy link
Contributor

I've opened tanloong#1 instead of more suggestions here. It greatly simplifies your tests.

tanloong added 7 commits May 11, 2025 01:29
"NO_COLOR" only affects color for the `sqlite>` prompt, it does not help
for disabling color of completion prefix.
4-space indents; 79 line width; outermost curly braces in column 1 in
function definition; blank line after local variable declaration;
@tanloong
Copy link
Contributor Author

The failed test is unrelated to this PR.

@tanloong
Copy link
Contributor Author

Hi @picnixz , I have found way to avoid colored output in run_pty()(explained here) and moved KEYWORDS to C. Could you take a look at this when you have some spare time?

@picnixz picnixz self-requested a review May 15, 2025 12:21
Comment on lines +73 to +77
sqlite3
-------

* Support keyword completion in the :mod:`sqlite3` command-line interface.
(Contributed by Tan Long in :gh:`133393`.)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move this under the "Improved modules" section instead of "New features". The "New features" is reserved for PEP-sized items.

Comment on lines +9 to +10
if state == 0:
_completion_matches = [c + " " for c in SQLITE_KEYWORDS if c.startswith(text.upper())]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if state == 0:
_completion_matches = [c + " " for c in SQLITE_KEYWORDS if c.startswith(text.upper())]
if state == 0:
text_upper = text.upper()
_completion_matches = [c for c in SQLITE_KEYWORDS if c.startswith(text_upper)]

Otherwise, I think text.upper() is going to be called for each c. Then you can do return _completion_matches[state] + " " instead (no need to store the extra whitespace) as it would create a new string while here you can just create a new reference to an existing string.

@@ -200,5 +205,74 @@ def test_color(self):
self.assertIn('\x1b[1;35mOperationalError (SQLITE_ERROR)\x1b[0m: '
'\x1b[35mnear "sel": syntax error\x1b[0m', err)

@requires_subprocess()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@requires_subprocess()
@requires_subprocess()

Two blank lines to separate classes.

def setUpClass(cls):
readline = import_module("readline")
if readline.backend == "editline":
raise unittest.SkipTest("libedit readline is not supported")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's incorrect to use self.skipTest. We're in a class method so we can only raise the exception. skipTest is a regular method, not a class method.

Comment on lines +219 to +223
script = textwrap.dedent("""
import readline
readline.parse_and_bind("set colored-completion-prefix off")
from sqlite3.__main__ import main; main()
""")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can have a helper method:

def write_input(self, input):
    script = textwrap.dedent("""
        import readline
        readline.parse_and_bind("set colored-completion-prefix off")
        from sqlite3.__main__ import main; main()
    """)
    return run_pty(script, input)

output = run_pty(script, input, env={"NO_COLOR": "1"})
output_lines = output.decode().splitlines()
candidates = []
for line in output_lines[-2::-1]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we iterating in reverse order and then sorting the candidates in reverse order as well?

if line.startswith(self.PS1):
break
candidates.append(line.strip())
self.assertEqual(sorted(candidates, reverse=True), candidates)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused here. Are we checking that candidates are already sorted? or maybe you wanted to check against SQLITE_KEYWORDS?

import unittest

from _sqlite3 import SQLITE_KEYWORDS
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When are we using it?

Comment on lines +276 to 277

if __name__ == "__main__":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if __name__ == "__main__":
if __name__ == "__main__":

add_sequence_constants(PyObject *module)
{
PyObject *kwd;
const char *_keywords[] = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When I said "we need it in C", I meant, we need the list of keywords given by sqlite3 directly, not by hardcoding it in C. Hardcoding it in C doesn't help.

@bedevere-app
Copy link

bedevere-app bot commented May 15, 2025

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants