Skip to content

Commit 27647cd

Browse files
committed
typecheck for several files including filepaths.py
add type annotations for * mycli/__init__.py * mycli/compat.py * mycli/lexer.py * mycli/packages/filepaths.py * mycli/packages/hybrid_redirection.py * mycli/packages/parseutils.py * mycli/packages/special/delimitercommand.py * mycli/packages/toolkit/fzf.py * mycli/packages/toolkit/history.py * mycli/packages/special/favoritequeries.py * mycli/packages/special/utils.py * mycli/packages/tabular_output/sql_format.py and enable mypy checking for them in CI. Convert capitalized types in toolkit/history.py to modern annotations. Fix an int() cast on an env variable in test/utils.py, without enabling typecheck for the entire file. Make query_starts_with() and queries_starts_with() take a list rather than a tuple for the second argument. Fix a bug where extract_tables_from_complete_statements could crash without checking for a None. Fix a bug where table_format was not checked for None before treating as a string. Move __main__ section of parseutils.py to bottom of file. Consider removing it. Remove a needless inherit from "object". Convert an if/elif to the guard clause pattern.
1 parent 9379f4e commit 27647cd

15 files changed

Lines changed: 90 additions & 82 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Internal
55
--------
66

77
* Add limited typechecking to CI.
8+
* Enable typechecking for several files.
89

910

1011
1.35.0 (2025/07/18)

mycli/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
# type: ignore
1+
from __future__ import annotations
22

33
import importlib.metadata
44

5-
__version__ = importlib.metadata.version("mycli")
5+
__version__: str = importlib.metadata.version("mycli")

mycli/compat.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# type: ignore
2-
31
"""Platform and Python version compatibility support."""
42

3+
from __future__ import annotations
4+
55
import sys
66

7-
WIN = sys.platform in ("win32", "cygwin")
7+
WIN: bool = sys.platform in ("win32", "cygwin")

mycli/lexer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# type: ignore
1+
from __future__ import annotations
22

33
from pygments.lexer import inherit
44
from pygments.lexers.sql import MySqlLexer

mycli/packages/filepaths.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
1-
# type: ignore
1+
from __future__ import annotations
22

33
import os
44
import platform
55

6+
DEFAULT_SOCKET_DIRS: list[str] = []
67
if os.name == "posix":
78
if platform.system() == "Darwin":
89
DEFAULT_SOCKET_DIRS = ["/tmp"]
910
else:
1011
DEFAULT_SOCKET_DIRS = ["/var/run", "/var/lib"]
11-
else:
12-
DEFAULT_SOCKET_DIRS = []
1312

1413

15-
def list_path(root_dir):
14+
def list_path(root_dir: str) -> list[str]:
1615
"""List directory if exists.
1716
1817
:param root_dir: str
@@ -26,7 +25,7 @@ def list_path(root_dir):
2625
return res
2726

2827

29-
def complete_path(curr_dir, last_dir):
28+
def complete_path(curr_dir: str, last_dir: str) -> str:
3029
"""Return the path to complete that matches the last entered component.
3130
3231
If the last entered component is ~, expanded path would not
@@ -41,9 +40,11 @@ def complete_path(curr_dir, last_dir):
4140
return curr_dir
4241
elif last_dir == "~":
4342
return os.path.join(last_dir, curr_dir)
43+
else:
44+
return ''
4445

4546

46-
def parse_path(root_dir):
47+
def parse_path(root_dir: str) -> tuple[str, str, int]:
4748
"""Split path into head and last component for the completer.
4849
4950
Also return position where last component starts.
@@ -59,7 +60,7 @@ def parse_path(root_dir):
5960
return base_dir, last_dir, position
6061

6162

62-
def suggest_path(root_dir):
63+
def suggest_path(root_dir: str) -> list[str]:
6364
"""List all files and subdirectories in a directory.
6465
6566
If the directory is not specified, suggest root directory,
@@ -81,7 +82,7 @@ def suggest_path(root_dir):
8182
return list_path(root_dir)
8283

8384

84-
def dir_path_exists(path):
85+
def dir_path_exists(path: str) -> bool:
8586
"""Check if the directory path exists for a given file.
8687
8788
For example, for a file /home/user/.cache/mycli/log, check if
@@ -94,7 +95,7 @@ def dir_path_exists(path):
9495
return os.path.exists(os.path.dirname(path))
9596

9697

97-
def guess_socket_location():
98+
def guess_socket_location() -> str | None:
9899
"""Try to guess the location of the default mysql socket file."""
99100
socket_dirs = filter(os.path.exists, DEFAULT_SOCKET_DIRS)
100101
for directory in socket_dirs:

mycli/packages/hybrid_redirection.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55

66
import sqlglot
77

8-
from mycli.compat import WIN # type: ignore[attr-defined]
9-
from mycli.packages.special.delimitercommand import DelimiterCommand # type: ignore[attr-defined]
8+
from mycli.compat import WIN
9+
from mycli.packages.special.delimitercommand import DelimiterCommand
1010

1111
logger = logging.getLogger(__name__)
1212
delimiter_command = DelimiterCommand()

mycli/packages/parseutils.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
# type: ignore
1+
from __future__ import annotations
22

33
import re
4+
from typing import Generator
45

56
import sqlglot
6-
import sqlparse # type: ignore[import-untyped]
7-
from sqlparse.sql import Function, Identifier, IdentifierList # type: ignore[import-untyped]
8-
from sqlparse.tokens import DML, Keyword, Punctuation # type: ignore[import-untyped]
7+
import sqlparse
8+
from sqlparse.sql import Function, Identifier, IdentifierList, Token, TokenList
9+
from sqlparse.tokens import DML, Keyword, Punctuation
910

10-
cleanup_regex = {
11+
cleanup_regex: dict[str, re.Pattern] = {
1112
# This matches only alphanumerics and underscores.
1213
"alphanum_underscore": re.compile(r"(\w+)$"),
1314
# This matches everything except spaces, parens, colon, and comma
@@ -19,7 +20,7 @@
1920
}
2021

2122

22-
def last_word(text, include="alphanum_underscore"):
23+
def last_word(text: str, include: str = "alphanum_underscore") -> str:
2324
r"""
2425
Find the last word in a sentence.
2526
@@ -67,7 +68,7 @@ def last_word(text, include="alphanum_underscore"):
6768

6869
# This code is borrowed from sqlparse example script.
6970
# <url>
70-
def is_subselect(parsed):
71+
def is_subselect(parsed: TokenList) -> bool:
7172
if not parsed.is_group:
7273
return False
7374
for item in parsed.tokens:
@@ -76,15 +77,15 @@ def is_subselect(parsed):
7677
return False
7778

7879

79-
def extract_from_part(parsed, stop_at_punctuation=True):
80+
def extract_from_part(parsed: TokenList, stop_at_punctuation: bool = True) -> Generator[str, None, None]:
8081
tbl_prefix_seen = False
8182
for item in parsed.tokens:
8283
if tbl_prefix_seen:
8384
if is_subselect(item):
8485
for x in extract_from_part(item, stop_at_punctuation):
8586
yield x
8687
elif stop_at_punctuation and item.ttype is Punctuation:
87-
return
88+
return None
8889
# Multiple JOINs in the same query won't work properly since
8990
# "ON" is a keyword and will trigger the next elif condition.
9091
# So instead of stooping the loop when finding an "ON" skip it
@@ -101,7 +102,7 @@ def extract_from_part(parsed, stop_at_punctuation=True):
101102
# condition. So we need to ignore the keyword JOIN and its variants
102103
# INNER JOIN, FULL OUTER JOIN, etc.
103104
elif item.ttype is Keyword and (not item.value.upper() == "FROM") and (not item.value.upper().endswith("JOIN")):
104-
return
105+
return None
105106
else:
106107
yield item
107108
elif (item.ttype is Keyword or item.ttype is Keyword.DML) and item.value.upper() in (
@@ -122,7 +123,7 @@ def extract_from_part(parsed, stop_at_punctuation=True):
122123
break
123124

124125

125-
def extract_table_identifiers(token_stream):
126+
def extract_table_identifiers(token_stream: TokenList) -> Generator[tuple[str | None, str, str]]:
126127
"""yields tuples of (schema_name, table_name, table_alias)"""
127128

128129
for item in token_stream:
@@ -151,7 +152,7 @@ def extract_table_identifiers(token_stream):
151152

152153

153154
# extract_tables is inspired from examples in the sqlparse lib.
154-
def extract_tables(sql):
155+
def extract_tables(sql: str) -> list[tuple[str | None, str, str]]:
155156
"""Extract the table names from an SQL statement.
156157
157158
Returns a list of (schema, table, alias) tuples
@@ -170,7 +171,7 @@ def extract_tables(sql):
170171
return list(extract_table_identifiers(stream))
171172

172173

173-
def extract_tables_from_complete_statements(sql):
174+
def extract_tables_from_complete_statements(sql: str) -> list[tuple[str | None, str, str | None]]:
174175
"""Extract the table names from a complete and valid series of SQL
175176
statements.
176177
@@ -195,7 +196,7 @@ def extract_tables_from_complete_statements(sql):
195196
tables = []
196197
for statement in finely_parsed:
197198
for identifier in statement.find_all(sqlglot.exp.Table):
198-
if identifier.parent_select.sql().startswith('WITH'):
199+
if identifier.parent_select and identifier.parent_select.sql().startswith('WITH'):
199200
continue
200201
tables.append((
201202
None if identifier.db == '' else identifier.db,
@@ -206,7 +207,7 @@ def extract_tables_from_complete_statements(sql):
206207
return tables
207208

208209

209-
def find_prev_keyword(sql):
210+
def find_prev_keyword(sql: str) -> tuple[Token | None, str]:
210211
"""Find the last sql keyword in an SQL statement
211212
212213
Returns the value of the last keyword, and the text of the query with
@@ -240,45 +241,40 @@ def find_prev_keyword(sql):
240241
return None, ""
241242

242243

243-
def query_starts_with(query, prefixes):
244+
def query_starts_with(query: str, prefixes: list[str]) -> bool:
244245
"""Check if the query starts with any item from *prefixes*."""
245246
prefixes = [prefix.lower() for prefix in prefixes]
246247
formatted_sql = sqlparse.format(query.lower(), strip_comments=True)
247248
return bool(formatted_sql) and formatted_sql.split()[0] in prefixes
248249

249250

250-
def queries_start_with(queries, prefixes):
251+
def queries_start_with(queries: str, prefixes: list[str]) -> bool:
251252
"""Check if any queries start with any item from *prefixes*."""
252253
for query in sqlparse.split(queries):
253254
if query and query_starts_with(query, prefixes) is True:
254255
return True
255256
return False
256257

257258

258-
def query_has_where_clause(query):
259+
def query_has_where_clause(query: str) -> bool:
259260
"""Check if the query contains a where-clause."""
260261
return any(isinstance(token, sqlparse.sql.Where) for token_list in sqlparse.parse(query) for token in token_list)
261262

262263

263-
def is_destructive(queries):
264+
def is_destructive(queries: str) -> bool:
264265
"""Returns if any of the queries in *queries* is destructive."""
265266
keywords = ("drop", "shutdown", "delete", "truncate", "alter")
266267
for query in sqlparse.split(queries):
267268
if query:
268-
if query_starts_with(query, keywords) is True:
269+
if query_starts_with(query, list(keywords)) is True:
269270
return True
270271
elif query_starts_with(query, ["update"]) is True and not query_has_where_clause(query):
271272
return True
272273

273274
return False
274275

275276

276-
if __name__ == "__main__":
277-
sql = "select * from (select t. from tabl t"
278-
print(extract_tables(sql))
279-
280-
281-
def is_dropping_database(queries, dbname):
277+
def is_dropping_database(queries: list[str], dbname: str | None) -> bool:
282278
"""Determine if the query is dropping a specific database."""
283279
result = False
284280
if dbname is None:
@@ -301,3 +297,8 @@ def normalize_db_name(db):
301297
if database_token is not None and normalize_db_name(database_token.get_name()) == dbname:
302298
result = keywords[0].normalized == "DROP"
303299
return result
300+
301+
302+
if __name__ == "__main__":
303+
sql = "select * from (select t. from tabl t"
304+
print(extract_tables(sql))

mycli/packages/special/delimitercommand.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
# type: ignore
1+
from __future__ import annotations
22

33
import re
4+
from typing import Generator
45

56
import sqlparse # type: ignore[import-untyped]
67

78

89
class DelimiterCommand(object):
9-
def __init__(self):
10+
def __init__(self) -> None:
1011
self._delimiter = ";"
1112

12-
def _split(self, sql):
13+
def _split(self, sql: str) -> list[str]:
1314
"""Temporary workaround until sqlparse.split() learns about custom
1415
delimiters."""
1516

@@ -29,7 +30,7 @@ def _split(self, sql):
2930

3031
return [stmt.replace(";", self._delimiter).replace(placeholder, ";") for stmt in split]
3132

32-
def queries_iter(self, input_str):
33+
def queries_iter(self, input_str: str) -> Generator[str, None, None]:
3334
"""Iterate over queries in the input string."""
3435

3536
queries = self._split(input_str)
@@ -54,7 +55,7 @@ def queries_iter(self, input_str):
5455
combined_statement += delimiter
5556
queries = self._split(combined_statement)[1:]
5657

57-
def set(self, arg, **_):
58+
def set(self, arg: str, **_) -> list[tuple[None, None, None, str]]:
5859
"""Change delimiter.
5960
6061
Since `arg` is everything that follows the DELIMITER token
@@ -76,5 +77,5 @@ def set(self, arg, **_):
7677
return [(None, None, None, "Changed delimiter to {}".format(delimiter))]
7778

7879
@property
79-
def current(self):
80+
def current(self) -> str:
8081
return self._delimiter

mycli/packages/special/favoritequeries.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
# type: ignore
1+
from __future__ import annotations
22

33

4-
class FavoriteQueries(object):
5-
section_name = "favorite_queries"
4+
class FavoriteQueries:
5+
section_name: str = "favorite_queries"
66

77
usage = """
88
Favorite Queries are a way to save frequently used queries
@@ -36,27 +36,27 @@ class FavoriteQueries(object):
3636
# Class-level variable, for convenience to use as a singleton.
3737
instance = None
3838

39-
def __init__(self, config):
39+
def __init__(self, config) -> None:
4040
self.config = config
4141

4242
@classmethod
4343
def from_config(cls, config):
4444
return FavoriteQueries(config)
4545

46-
def list(self):
46+
def list(self) -> list[str | None]:
4747
return self.config.get(self.section_name, [])
4848

49-
def get(self, name):
49+
def get(self, name) -> str | None:
5050
return self.config.get(self.section_name, {}).get(name, None)
5151

52-
def save(self, name, query):
52+
def save(self, name: str, query: str) -> None:
5353
self.config.encoding = "utf-8"
5454
if self.section_name not in self.config:
5555
self.config[self.section_name] = {}
5656
self.config[self.section_name][name] = query
5757
self.config.write()
5858

59-
def delete(self, name):
59+
def delete(self, name: str) -> str:
6060
try:
6161
del self.config[self.section_name][name]
6262
except KeyError:

0 commit comments

Comments
 (0)