Skip to content

Commit 1f7c432

Browse files
authored
Merge pull request #246 from dbcli/amjith/llm-optional
Make the llm library optional
2 parents db28530 + 1991130 commit 1f7c432

10 files changed

Lines changed: 159 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
## Unreleased
2+
3+
### Features
4+
5+
- Make LLM support optional and installable via `litecli[ai]`.
6+
7+
### Bug Fixes
8+
9+
- Avoid completion refresh crashes when no database is connected.
10+
11+
### Internal
12+
13+
- Clean up ty type-checking for optional sqlean/llm imports.
14+
- Add an llm module alias for test patching.
15+
- Avoid ty conflicts for optional sqlite/llm imports.
16+
117
## 1.18.0
218

319
### Internal

litecli/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# type: ignore
21
from __future__ import annotations
32

43
import importlib.metadata

litecli/main.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,6 @@
1111
from collections import namedtuple
1212
from datetime import datetime
1313
from io import open
14-
15-
try:
16-
from sqlean import OperationalError, sqlite_version
17-
except ImportError:
18-
from sqlite3 import OperationalError, sqlite_version
1914
from time import time
2015
from typing import Any, Generator, Iterable, cast
2116

@@ -51,6 +46,21 @@
5146
from .sqlcompleter import SQLCompleter
5247
from .sqlexecute import SQLExecute
5348

49+
50+
def _load_sqlite3() -> Any:
51+
try:
52+
import sqlean
53+
except ImportError:
54+
import sqlite3
55+
56+
return sqlite3
57+
return sqlean
58+
59+
60+
_sqlite3 = _load_sqlite3()
61+
OperationalError = _sqlite3.OperationalError
62+
sqlite_version = _sqlite3.sqlite_version
63+
5464
# Query tuples are used for maintaining history
5565
Query = namedtuple("Query", ["query", "successful", "mutating"])
5666

litecli/packages/special/llm.py

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import importlib
45
import io
56
import logging
67
import os
@@ -13,20 +14,53 @@
1314
from typing import Any
1415

1516
import click
16-
import llm
17-
from llm.cli import cli
1817

1918
from . import export
2019
from .main import Verbosity, parse_special_command
2120
from .types import DBCursor
2221

22+
23+
def _load_llm_module() -> Any | None:
24+
try:
25+
return importlib.import_module("llm")
26+
except ImportError:
27+
return None
28+
29+
30+
def _load_llm_cli_module() -> Any | None:
31+
try:
32+
return importlib.import_module("llm.cli")
33+
except ImportError:
34+
return None
35+
36+
37+
llm_module = _load_llm_module()
38+
llm_cli_module = _load_llm_cli_module()
39+
40+
# Alias for tests and patching.
41+
llm = llm_module
42+
43+
LLM_IMPORTED = llm_module is not None
44+
45+
cli: click.Command | None
46+
if llm_cli_module is not None:
47+
llm_cli = getattr(llm_cli_module, "cli", None)
48+
cli = llm_cli if isinstance(llm_cli, click.Command) else None
49+
else:
50+
cli = None
51+
52+
LLM_CLI_IMPORTED = cli is not None
53+
2354
log = logging.getLogger(__name__)
2455

2556
LLM_TEMPLATE_NAME = "litecli-llm-template"
26-
LLM_CLI_COMMANDS: list[str] = list(cli.commands.keys())
57+
LLM_CLI_COMMANDS: list[str] = list(cli.commands.keys()) if isinstance(cli, click.Group) else []
2758
# Mapping of model_id to None used for completion tree leaves.
28-
# the file name is llm.py and module name is llm, hence ty is complaining that get_models is missing.
29-
MODELS: dict[str, None] = {x.model_id: None for x in llm.get_models()} # type: ignore[attr-defined]
59+
if llm_module is not None:
60+
get_models = getattr(llm_module, "get_models", None)
61+
MODELS: dict[str, None] = {x.model_id: None for x in get_models()} if callable(get_models) else {}
62+
else:
63+
MODELS = {}
3064

3165

3266
def run_external_cmd(
@@ -110,7 +144,7 @@ def build_command_tree(cmd: click.Command) -> dict[str, Any] | None:
110144

111145

112146
# Generate the tree
113-
COMMAND_TREE: dict[str, Any] | None = build_command_tree(cli)
147+
COMMAND_TREE: dict[str, Any] | None = build_command_tree(cli) if cli is not None else {}
114148

115149

116150
def get_completions(tokens: list[str], tree: dict[str, Any] | None = COMMAND_TREE) -> list[str]:
@@ -123,6 +157,8 @@ def get_completions(tokens: list[str], tree: dict[str, Any] | None = COMMAND_TRE
123157
Returns:
124158
list[str]: List of possible completions.
125159
"""
160+
if not LLM_CLI_IMPORTED:
161+
return []
126162
for token in tokens:
127163
if token.startswith("-"):
128164
# Skip options (flags)
@@ -171,6 +207,18 @@ def __init__(self, results: Any | None = None) -> None:
171207
# https://llm.datasette.io/en/stable/plugins/directory.html
172208
"""
173209

210+
NEED_DEPENDENCIES = """
211+
To enable LLM features you need to install litecli with AI support:
212+
213+
pip install 'litecli[ai]'
214+
215+
or install LLM libraries separately
216+
217+
pip install llm
218+
219+
This is required to use the \\llm command.
220+
"""
221+
174222
_SQL_CODE_FENCE = r"```sql\n(.*?)\n```"
175223
PROMPT = """
176224
You are a helpful assistant who is a SQLite expert. You are embedded in a SQLite
@@ -230,6 +278,10 @@ def handle_llm(text: str, cur: DBCursor) -> tuple[str, str | None, float]:
230278
is_verbose = mode is Verbosity.VERBOSE
231279
is_succinct = mode is Verbosity.SUCCINCT
232280

281+
if not LLM_IMPORTED:
282+
output = [(None, None, None, NEED_DEPENDENCIES)]
283+
raise FinishIteration(output)
284+
233285
if not arg.strip(): # No question provided. Print usage and bail.
234286
output = [(None, None, None, USAGE)]
235287
raise FinishIteration(output)

litecli/packages/special/main.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88

99
log = logging.getLogger(__name__)
1010

11+
try:
12+
import llm # noqa: F401
13+
14+
LLM_IMPORTED = True
15+
except ImportError:
16+
LLM_IMPORTED = False
17+
1118
NO_QUERY = 0
1219
PARSED_QUERY = 1
1320
RAW_QUERY = 2
@@ -176,13 +183,19 @@ def quit(*_args: Any) -> None:
176183
arg_type=NO_QUERY,
177184
case_sensitive=True,
178185
)
179-
@special_command(
180-
"\\llm",
181-
"\\ai",
182-
"Use LLM to construct a SQL query.",
183-
arg_type=NO_QUERY,
184-
case_sensitive=False,
185-
aliases=(".ai", ".llm"),
186-
)
187186
def stub() -> None:
188187
raise NotImplementedError
188+
189+
190+
if LLM_IMPORTED:
191+
192+
@special_command(
193+
"\\llm",
194+
"\\ai",
195+
"Use LLM to construct a SQL query.",
196+
arg_type=NO_QUERY,
197+
case_sensitive=False,
198+
aliases=(".ai", ".llm"),
199+
)
200+
def llm_stub() -> None:
201+
raise NotImplementedError

litecli/sqlexecute.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,26 @@
11
from __future__ import annotations
22

33
import logging
4+
import os.path
45
from contextlib import closing
5-
from typing import Any, Generator, Iterable
6+
from typing import Any, Generator, Iterable, cast
7+
from urllib.parse import urlparse
8+
9+
import sqlparse
610

711
try:
8-
import sqlean as sqlite3
9-
from sqlean import OperationalError
12+
import sqlean as _sqlite3
1013

11-
sqlite3.extensions.enable_all()
14+
_sqlite3.extensions.enable_all()
1215
except ImportError:
13-
import sqlite3
14-
from sqlite3 import OperationalError
15-
import os.path
16-
from urllib.parse import urlparse
17-
18-
import sqlparse
16+
import sqlite3 as _sqlite3
1917

2018
from litecli.packages import special
2119
from litecli.packages.special.utils import check_if_sqlitedotcommand
2220

21+
sqlite3 = cast(Any, _sqlite3)
22+
OperationalError = sqlite3.OperationalError
23+
2324
_logger = logging.getLogger(__name__)
2425

2526
# FIELD_TYPES = decoders.copy()
@@ -179,7 +180,8 @@ def get_result(self, cursor: Any) -> tuple[str | None, list | None, list | None,
179180

180181
def tables(self) -> Generator[tuple[str], None, None]:
181182
"""Yields table names"""
182-
assert self.conn is not None
183+
if not self.conn:
184+
return
183185
with closing(self.conn.cursor()) as cur:
184186
_logger.debug("Tables Query. sql: %r", self.tables_query)
185187
cur.execute(self.tables_query)
@@ -188,7 +190,8 @@ def tables(self) -> Generator[tuple[str], None, None]:
188190

189191
def table_columns(self) -> Generator[tuple[str, str], None, None]:
190192
"""Yields column names"""
191-
assert self.conn is not None
193+
if not self.conn:
194+
return
192195
with closing(self.conn.cursor()) as cur:
193196
_logger.debug("Columns Query. sql: %r", self.table_columns_query)
194197
cur.execute(self.table_columns_query)
@@ -206,7 +209,8 @@ def databases(self) -> Generator[str, None, None]:
206209

207210
def functions(self) -> Iterable[tuple]:
208211
"""Yields tuples of (schema_name, function_name)"""
209-
assert self.conn is not None
212+
if not self.conn:
213+
return
210214
with closing(self.conn.cursor()) as cur:
211215
_logger.debug("Functions Query. sql: %r", self.functions_query)
212216
cur.execute(self.functions_query % self.dbname)

pyproject.toml

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@ dependencies = [
1313
"configobj>=5.0.5",
1414
"prompt-toolkit>=3.0.3,<4.0.0",
1515
"pygments>=1.6",
16-
"sqlparse>=0.4.4",
17-
"setuptools", # Required by llm commands to install models
18-
"pip",
19-
"llm>=0.25.0"
16+
"sqlparse>=0.4.4"
2017
]
2118

2219
[build-system]
@@ -33,7 +30,11 @@ build-backend = "setuptools.build_meta"
3330
litecli = "litecli.main:cli"
3431

3532
[project.optional-dependencies]
36-
ai = ["llm"]
33+
ai = [
34+
"llm>=0.25.0",
35+
"setuptools", # Required by llm commands to install models
36+
"pip",
37+
]
3738
sqlean = ["sqlean-py>=3.47.0",
3839
"sqlean-stubs>=0.0.3"]
3940

@@ -45,7 +46,9 @@ dev = [
4546
"pytest-cov>=4.1.0",
4647
"tox>=4.8.0",
4748
"pdbpp>=0.10.3",
48-
"llm>=0.19.0",
49+
"llm>=0.25.0",
50+
"setuptools",
51+
"pip",
4952
"ty>=0.0.4"
5053
]
5154

tests/test_llm_special.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22

33
import pytest
44

5+
import litecli.packages.special.llm as llm_module
56
from litecli.packages.special.llm import USAGE, FinishIteration, handle_llm
67

78

9+
@pytest.fixture(autouse=True)
10+
def enable_llm(monkeypatch):
11+
monkeypatch.setattr(llm_module, "LLM_IMPORTED", True)
12+
monkeypatch.setattr(llm_module, "LLM_CLI_COMMANDS", ["models"])
13+
14+
815
@patch("litecli.packages.special.llm.llm")
916
def test_llm_command_without_args(mock_llm, executor):
1017
r"""

tests/test_main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ def stub_terminal_size():
243243
shutil.get_terminal_size = stub_terminal_size # type: ignore[assignment]
244244
lc = LiteCli()
245245
assert isinstance(lc.get_reserved_space(), int)
246-
shutil.get_terminal_size = old_func # type: ignore[assignment]
246+
shutil.get_terminal_size = old_func
247247

248248

249249
@dbtest

tests/test_sqlexecute.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,26 @@
11
# coding=UTF-8
22

33
import os
4+
from typing import Any
45

56
import pytest
67

78
from .utils import assert_result_equal, dbtest, is_expanded_output, run, set_expanded_output
89

9-
try:
10-
from sqlean import OperationalError, ProgrammingError
11-
except ImportError:
12-
from sqlite3 import OperationalError, ProgrammingError
10+
11+
def _load_sqlite3() -> Any:
12+
try:
13+
import sqlean
14+
except ImportError:
15+
import sqlite3
16+
17+
return sqlite3
18+
return sqlean
19+
20+
21+
_sqlite3 = _load_sqlite3()
22+
OperationalError = _sqlite3.OperationalError
23+
ProgrammingError = _sqlite3.ProgrammingError
1324

1425

1526
@dbtest

0 commit comments

Comments
 (0)