Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/workflows/typecheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ jobs:
- name: Install dependencies
run: uv sync --all-extras

- name: Run mypy
- name: Run ty
run: |
cd litecli
uv run --no-sync --frozen -- python -m ensurepip
uv run --no-sync --frozen -- python -m mypy --no-pretty --install-types --non-interactive .
uv run ty check -v
7 changes: 3 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,11 @@
- Lint: `ruff check` (add `--fix` to auto-fix)
- Format: `ruff format`

### Mypy (type checking)
- Repo-wide (recommended): `mypy --explicit-package-bases .`
- Per-package: `mypy --explicit-package-bases litecli`
## ty (type checking)
- Repo-wide `ty check -v`
- Per-package: `ty check litecli -v`
- Notes:
- Config is in `pyproject.toml` (target Python 3.9, stricter settings).
- Use `--explicit-package-bases` to avoid module discovery issues when running outside tox.

## Coding Style & Naming Conventions
- Formatter/linter: Ruff (configured via `.pre-commit-config.yaml` and `tox`).
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.18.0

### Internal

- Switch mypy to ty for type checking. [(#242)](https://github.com/dbcli/litecli/pull/242/files)

## 1.17.0 - 2025-09-28

### Features
Expand Down
19 changes: 10 additions & 9 deletions litecli/clistyle.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
from __future__ import annotations

import logging

from typing import cast

import pygments.styles
from pygments.token import string_to_tokentype, Token
from pygments.style import Style as PygmentsStyle
from pygments.util import ClassNotFound
from prompt_toolkit.styles import Style, merge_styles
from prompt_toolkit.styles.pygments import style_from_pygments_cls
from prompt_toolkit.styles import merge_styles, Style
from prompt_toolkit.styles.style import _MergedStyle
from pygments.style import Style as PygmentsStyle
from pygments.token import Token, _TokenType, string_to_tokentype
from pygments.util import ClassNotFound

logger = logging.getLogger(__name__)

# map Pygments tokens (ptk 1.0) to class names (ptk 2.0).
TOKEN_TO_PROMPT_STYLE: dict[Token, str] = {
TOKEN_TO_PROMPT_STYLE: dict[_TokenType, str] = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mapping to accurate type so ty doesn't complain.

Token.Menu.Completions.Completion.Current: "completion-menu.completion.current",
Token.Menu.Completions.Completion: "completion-menu.completion",
Token.Menu.Completions.Meta.Current: "completion-menu.meta.completion.current",
Expand Down Expand Up @@ -43,10 +43,10 @@
}

# reverse dict for cli_helpers, because they still expect Pygments tokens.
PROMPT_STYLE_TO_TOKEN: dict[str, Token] = {v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()}
PROMPT_STYLE_TO_TOKEN: dict[str, _TokenType] = {v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()}


def parse_pygments_style(token_name: str, style_object: PygmentsStyle | dict, style_dict: dict[str, str]) -> tuple[Token, str]:
def parse_pygments_style(token_name: str, style_object: PygmentsStyle | dict, style_dict: dict[str, str]) -> tuple[_TokenType, str]:
"""Parse token type and style string.

:param token_name: str name of Pygments token. Example: "Token.String"
Expand Down Expand Up @@ -111,4 +111,5 @@ class OutputStyle(PygmentsStyle):
default_style = ""
styles = style

return OutputStyle
# mypy does not complain but ty complains: error[invalid-return-type]: Return type does not match returned value. Hence added cast.
return cast(OutputStyle, PygmentsStyle)
11 changes: 6 additions & 5 deletions litecli/completion_refresher.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from __future__ import annotations

import threading
from typing import Callable

from .packages.special.main import COMMANDS
from collections import OrderedDict
from typing import Callable, cast

from .packages.special.main import COMMANDS
from .sqlcompleter import SQLCompleter
from .sqlexecute import SQLExecute

Expand Down Expand Up @@ -77,7 +76,9 @@ def _bg_refresh(

# If callbacks is a single function then push it into a list.
if callable(callbacks):
callbacks = [callbacks]
callbacks_list: list[Callable] = [callbacks]
else:
callbacks_list = list(cast(list[Callable], callbacks))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

ty was complaining about this, hence modified it.

Copy link
Member

Choose a reason for hiding this comment

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

This is weird. Wouldn't this work?

callbacks_list = ist(callbacks)

Copy link
Contributor Author

Choose a reason for hiding this comment

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


while 1:
for refresher in self.refreshers.values():
Expand All @@ -94,7 +95,7 @@ def _bg_refresh(
# break statement.
continue

for callback in callbacks:
for callback in callbacks_list:
callback(completer)


Expand Down
7 changes: 3 additions & 4 deletions litecli/config.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from __future__ import annotations

import errno
import shutil
import os
import platform
from os.path import expanduser, exists, dirname

import shutil
from os.path import dirname, exists, expanduser

from configobj import ConfigObj

Expand Down Expand Up @@ -55,7 +54,7 @@ def upgrade_config(config: str, def_config: str) -> None:
def get_config(liteclirc_file: str | None = None) -> ConfigObj:
from litecli import __file__ as package_root

package_root = os.path.dirname(package_root)
package_root = os.path.dirname(str(package_root))
Copy link
Contributor Author

Choose a reason for hiding this comment

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

ty doesn't like package_root as it comes from import reference, hence converting to string.


liteclirc_file = liteclirc_file or f"{config_location()}config"

Expand Down
31 changes: 17 additions & 14 deletions litecli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@
from io import open

try:
from sqlean import OperationalError, sqlite_version
from sqlean import OperationalError, sqlite_version # type: ignore[import-untyped]
except ImportError:
from sqlite3 import OperationalError, sqlite_version
from time import time
from typing import Any, Iterable
from typing import Any, Generator, Iterable, cast

import click
import sqlparse
from cli_helpers.tabular_output import TabularOutputFormatter, preprocessors
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.completion import DynamicCompleter
from prompt_toolkit.completion import Completion, DynamicCompleter
from prompt_toolkit.document import Document
from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
from prompt_toolkit.filters import HasFocus, IsDone
Expand All @@ -35,8 +35,6 @@
)
from prompt_toolkit.lexers import PygmentsLexer
from prompt_toolkit.shortcuts import CompleteStyle, PromptSession
from typing import cast
from prompt_toolkit.completion import Completion

from .__init__ import __version__
from .clibuffer import cli_is_multiline
Expand All @@ -53,8 +51,6 @@
from .sqlcompleter import SQLCompleter
from .sqlexecute import SQLExecute

click.disable_unicode_literals_warning = True
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is no longer needed in Python 3.9


# Query tuples are used for maintaining history
Query = namedtuple("Query", ["query", "successful", "mutating"])

Expand Down Expand Up @@ -84,7 +80,8 @@ def __init__(
self.key_bindings = c["main"]["key_bindings"]
special.set_favorite_queries(self.config)
self.formatter = TabularOutputFormatter(format_name=c["main"]["table_format"])
self.formatter.litecli = self
# self.formatter.litecli = self, ty raises unresolved-attribute, hence use dynamic assignment
setattr(self.formatter, "litecli", self)
self.syntax_style = c["main"]["syntax_style"]
self.less_chatty = c["main"].as_bool("less_chatty")
self.show_bottom_toolbar = c["main"].as_bool("show_bottom_toolbar")
Expand Down Expand Up @@ -181,7 +178,7 @@ def register_special_commands(self) -> None:
case_sensitive=True,
)

def change_table_format(self, arg: str, **_: Any) -> Iterable[tuple]:
def change_table_format(self, arg: str, **_: Any) -> Generator[tuple[None, None, None, str], None, None]:
try:
self.formatter.format_name = arg
yield (None, None, None, "Changed table format to {}".format(arg))
Expand All @@ -200,11 +197,14 @@ def change_db(self, arg: str | None, **_: Any) -> Iterable[tuple]:
self.sqlexecute.connect(database=arg)

self.refresh_completions()
# guard so that ty doesn't complain
dbname = self.sqlexecute.dbname if self.sqlexecute is not None else ""

yield (
None,
None,
None,
'You are now connected to database "%s"' % (self.sqlexecute.dbname),
'You are now connected to database "%s"' % (dbname),
)

def execute_from_file(self, arg: str | None, **_: Any) -> Iterable[tuple[Any, ...]]:
Expand Down Expand Up @@ -303,7 +303,7 @@ def get(key: str) -> str | None:

return {x: get(x) for x in keys}

def connect(self, database: str = "") -> None:
def connect(self, database: str | None = "") -> None:
cnf: dict[str, str | None] = {"database": None}

cnf = self.read_my_cnf_files(cnf.keys())
Expand Down Expand Up @@ -510,7 +510,8 @@ def one_iteration(text: str | None = None) -> None:
successful = False
start = time()
res = sqlexecute.run(text)
self.formatter.query = text
# Set query attribute dynamically on formatter
setattr(self.formatter, "query", text)
successful = True
special.unset_once_if_written()
# Keep track of whether or not the query is mutating. In case
Expand All @@ -522,7 +523,8 @@ def one_iteration(text: str | None = None) -> None:
raise e
except KeyboardInterrupt:
try:
sqlexecute.conn.interrupt()
# since connection can be sqlite3 or sqlean, it's hard to annotate the type for interrupt. so ignore the type hint warning.
sqlexecute.conn.interrupt() # type: ignore[attr-defined]
except Exception as e:
self.echo(
"Encountered error while cancelling query: {}".format(e),
Expand Down Expand Up @@ -755,6 +757,7 @@ def refresh_completions(self, reset: bool = False) -> list[tuple]:
if reset:
with self._completer_lock:
self.completer.reset_completions()
assert self.sqlexecute is not None
self.completion_refresher.refresh(
self.sqlexecute,
self._on_completions_refreshed,
Expand Down Expand Up @@ -815,7 +818,7 @@ def run_query(self, query: str, new_line: bool = True) -> None:
results = self.sqlexecute.run(query)
for result in results:
title, cur, headers, status = result
self.formatter.query = query
setattr(self.formatter, "query", query)
output = self.format_output(title, cur, headers)
for line in output:
click.echo(line, nl=new_line)
Expand Down
10 changes: 5 additions & 5 deletions litecli/packages/parseutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
from typing import Generator, Iterable, Literal

import sqlparse
from sqlparse.sql import IdentifierList, Identifier, Function, Token, TokenList
from sqlparse.tokens import Keyword, DML, Punctuation
from sqlparse.sql import Function, Identifier, IdentifierList, Token, TokenList
from sqlparse.tokens import DML, Keyword, Punctuation

cleanup_regex: dict[str, re.Pattern[str]] = {
# This matches only alphanumerics and underscores.
Expand All @@ -18,10 +18,10 @@
"all_punctuations": re.compile(r"([^\s]+)$"),
}

LAST_WORD_INCLUDE_TYPE = Literal["alphanum_underscore", "many_punctuations", "most_punctuations", "all_punctuations"]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ty later complains the value passed is str and so declare and import later.


def last_word(
text: str, include: Literal["alphanum_underscore", "many_punctuations", "most_punctuations", "all_punctuations"] = "alphanum_underscore"
) -> str:

def last_word(text: str, include: LAST_WORD_INCLUDE_TYPE = "alphanum_underscore") -> str:
R"""
Find the last word in a sentence.
Expand Down
28 changes: 26 additions & 2 deletions litecli/packages/special/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ruff: noqa

from __future__ import annotations
from types import FunctionType

from typing import Callable, Any

Expand All @@ -9,11 +10,34 @@

def export(defn: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator to explicitly mark functions that are exposed in a lib."""
globals()[defn.__name__] = defn
__all__.append(defn.__name__)
# ty, requires explict check for callable of tyep | function type to access __name__
if isinstance(defn, (type, FunctionType)):
globals()[defn.__name__] = defn
__all__.append(defn.__name__)
return defn


from . import dbcommands
from . import iocommands
from . import llm
from . import utils
from .main import CommandNotFound, register_special_command, execute
from .iocommands import (
set_favorite_queries,
editor_command,
get_filename,
get_editor_query,
open_external_editor,
is_expanded_output,
set_expanded_output,
write_tee,
unset_once_if_written,
unset_pipe_once_if_written,
disable_pager,
set_pager,
is_pager_enabled,
write_once,
write_pipe_once,
close_tee,
)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

ty doesn't like when these methods are accessed like special.xyz()when it is missing in the init.py

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There may be scope to improve over all import systems.

from .llm import is_llm_command, handle_llm, FinishIteration
3 changes: 2 additions & 1 deletion litecli/packages/special/favoritequeries.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import annotations

import builtins
from typing import Any, cast


Expand Down Expand Up @@ -39,7 +40,7 @@ class FavoriteQueries(object):
def __init__(self, config: Any) -> None:
self.config = config

def list(self) -> list[str]:
def list(self) -> builtins.list[str]:
Copy link
Contributor Author

Choose a reason for hiding this comment

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

ty is opinionated about this. astral-sh/ty#2035

section = cast(dict[str, str], self.config.get(self.section_name, {}))
return list(section.keys())

Expand Down
1 change: 1 addition & 0 deletions litecli/packages/special/iocommands.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def execute_favorite_query(cur: Any, arg: str, verbose: bool = False, **_: Any)
if arg_error:
yield (None, None, None, arg_error)
else:
assert query, "query should be non-empty"
for sql in sqlparse.split(query):
sql = sql.rstrip(";")
title = "> %s" % (sql) if verbose else None
Expand Down
3 changes: 2 additions & 1 deletion litecli/packages/special/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
LLM_TEMPLATE_NAME = "litecli-llm-template"
LLM_CLI_COMMANDS: list[str] = list(cli.commands.keys())
# Mapping of model_id to None used for completion tree leaves.
MODELS: dict[str, None] = {x.model_id: None for x in llm.get_models()}
# the file name is llm.py and module name is llm, hence ty is complaining that get_models is missing.
MODELS: dict[str, None] = {x.model_id: None for x in llm.get_models()} # type: ignore[attr-defined]


def run_external_cmd(
Expand Down
Loading