sqlsaber 0.26.0__py3-none-any.whl → 0.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlsaber/application/__init__.py +1 -0
- sqlsaber/application/auth_setup.py +164 -0
- sqlsaber/application/db_setup.py +222 -0
- sqlsaber/application/model_selection.py +98 -0
- sqlsaber/application/prompts.py +115 -0
- sqlsaber/cli/auth.py +24 -52
- sqlsaber/cli/commands.py +13 -2
- sqlsaber/cli/database.py +26 -87
- sqlsaber/cli/display.py +59 -40
- sqlsaber/cli/interactive.py +138 -131
- sqlsaber/cli/memory.py +2 -2
- sqlsaber/cli/models.py +20 -30
- sqlsaber/cli/onboarding.py +325 -0
- sqlsaber/cli/streaming.py +1 -1
- sqlsaber/cli/threads.py +35 -16
- sqlsaber/config/api_keys.py +4 -4
- sqlsaber/config/oauth_flow.py +3 -2
- sqlsaber/config/oauth_tokens.py +3 -5
- sqlsaber/database/base.py +6 -0
- sqlsaber/database/csv.py +5 -0
- sqlsaber/database/duckdb.py +5 -0
- sqlsaber/database/mysql.py +5 -0
- sqlsaber/database/postgresql.py +5 -0
- sqlsaber/database/sqlite.py +5 -0
- sqlsaber/theme/__init__.py +5 -0
- sqlsaber/theme/manager.py +219 -0
- sqlsaber/tools/sql_guard.py +225 -0
- sqlsaber/tools/sql_tools.py +10 -35
- {sqlsaber-0.26.0.dist-info → sqlsaber-0.28.0.dist-info}/METADATA +2 -1
- sqlsaber-0.28.0.dist-info/RECORD +61 -0
- sqlsaber-0.26.0.dist-info/RECORD +0 -52
- {sqlsaber-0.26.0.dist-info → sqlsaber-0.28.0.dist-info}/WHEEL +0 -0
- {sqlsaber-0.26.0.dist-info → sqlsaber-0.28.0.dist-info}/entry_points.txt +0 -0
- {sqlsaber-0.26.0.dist-info → sqlsaber-0.28.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Theme management for unified theming across Rich and prompt_toolkit."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from typing import Dict
|
|
8
|
+
|
|
9
|
+
from platformdirs import user_config_dir
|
|
10
|
+
from prompt_toolkit.styles import Style as PTStyle
|
|
11
|
+
from prompt_toolkit.styles.pygments import style_from_pygments_cls
|
|
12
|
+
from pygments.styles import get_style_by_name
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.theme import Theme
|
|
15
|
+
|
|
16
|
+
DEFAULT_THEME_NAME = "nord"
|
|
17
|
+
|
|
18
|
+
DEFAULT_ROLE_PALETTE = {
|
|
19
|
+
# base roles
|
|
20
|
+
"primary": "cyan",
|
|
21
|
+
"accent": "magenta",
|
|
22
|
+
"success": "green",
|
|
23
|
+
"warning": "yellow",
|
|
24
|
+
"error": "red",
|
|
25
|
+
"info": "cyan",
|
|
26
|
+
"muted": "dim",
|
|
27
|
+
# components
|
|
28
|
+
"table.header": "bold $primary",
|
|
29
|
+
"panel.border.user": "$info",
|
|
30
|
+
"panel.border.assistant": "$success",
|
|
31
|
+
"panel.border.thread": "$primary",
|
|
32
|
+
"spinner": "$warning",
|
|
33
|
+
"status": "$warning",
|
|
34
|
+
# domain-specific
|
|
35
|
+
"key.primary": "bold $warning",
|
|
36
|
+
"key.foreign": "bold $accent",
|
|
37
|
+
"key.index": "bold $primary",
|
|
38
|
+
"column.schema": "$info",
|
|
39
|
+
"column.name": "white",
|
|
40
|
+
"column.type": "$warning",
|
|
41
|
+
"heading": "bold $primary",
|
|
42
|
+
"section": "bold $accent",
|
|
43
|
+
"title": "bold $success",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Theme presets using exact Pygments colors
|
|
47
|
+
THEME_PRESETS = {
|
|
48
|
+
# Nord - exact colors from pygments nord theme
|
|
49
|
+
"nord": {
|
|
50
|
+
"primary": "#81a1c1", # Keyword (frost)
|
|
51
|
+
"accent": "#b48ead", # Number (aurora purple)
|
|
52
|
+
"success": "#a3be8c", # String (aurora green)
|
|
53
|
+
"warning": "#ebcb8b", # String.Escape (aurora yellow)
|
|
54
|
+
"error": "#bf616a", # Error/Generic.Error (aurora red)
|
|
55
|
+
"info": "#88c0d0", # Name.Function (frost cyan)
|
|
56
|
+
"muted": "dim",
|
|
57
|
+
},
|
|
58
|
+
# Dracula - exact colors from pygments dracula theme
|
|
59
|
+
"dracula": {
|
|
60
|
+
"primary": "#bd93f9", # purple
|
|
61
|
+
"accent": "#ff79c6", # pink
|
|
62
|
+
"success": "#50fa7b", # green
|
|
63
|
+
"warning": "#f1fa8c", # yellow
|
|
64
|
+
"error": "#ff5555", # red
|
|
65
|
+
"info": "#8be9fd", # cyan
|
|
66
|
+
"muted": "dim",
|
|
67
|
+
},
|
|
68
|
+
# Solarized Light - exact colors from pygments solarized-light theme
|
|
69
|
+
"solarized-light": {
|
|
70
|
+
"primary": "#268bd2", # blue
|
|
71
|
+
"accent": "#d33682", # magenta
|
|
72
|
+
"success": "#859900", # green
|
|
73
|
+
"warning": "#b58900", # yellow
|
|
74
|
+
"error": "#dc322f", # red
|
|
75
|
+
"info": "#2aa198", # cyan
|
|
76
|
+
"muted": "dim",
|
|
77
|
+
},
|
|
78
|
+
# VS (Visual Studio Light) - exact colors from pygments vs theme
|
|
79
|
+
"vs": {
|
|
80
|
+
"primary": "#0000ff", # Keyword (blue)
|
|
81
|
+
"accent": "#2b91af", # Keyword.Type/Name.Class
|
|
82
|
+
"success": "#008000", # Comment (green)
|
|
83
|
+
"warning": "#b58900", # (using solarized yellow as fallback)
|
|
84
|
+
"error": "#dc322f", # (using solarized red as fallback)
|
|
85
|
+
"info": "#2aa198", # (using solarized cyan as fallback)
|
|
86
|
+
"muted": "dim",
|
|
87
|
+
},
|
|
88
|
+
# Material (approximation based on material design colors)
|
|
89
|
+
"material": {
|
|
90
|
+
"primary": "#89ddff", # cyan
|
|
91
|
+
"accent": "#f07178", # pink/red
|
|
92
|
+
"success": "#c3e88d", # green
|
|
93
|
+
"warning": "#ffcb6b", # yellow
|
|
94
|
+
"error": "#ff5370", # red
|
|
95
|
+
"info": "#82aaff", # blue
|
|
96
|
+
"muted": "dim",
|
|
97
|
+
},
|
|
98
|
+
# One Dark - exact colors from pygments one-dark theme
|
|
99
|
+
"one-dark": {
|
|
100
|
+
"primary": "#c678dd", # Keyword (purple)
|
|
101
|
+
"accent": "#e06c75", # Name (red)
|
|
102
|
+
"success": "#98c379", # String (green)
|
|
103
|
+
"warning": "#e5c07b", # Keyword.Type (yellow)
|
|
104
|
+
"error": "#e06c75", # Name (red, used for errors)
|
|
105
|
+
"info": "#61afef", # Name.Function (blue)
|
|
106
|
+
"muted": "dim",
|
|
107
|
+
},
|
|
108
|
+
# Lightbulb - exact colors from pygments lightbulb theme (minimal dark)
|
|
109
|
+
"lightbulb": {
|
|
110
|
+
"primary": "#73d0ff", # Keyword.Type/Name.Class (blue_1)
|
|
111
|
+
"accent": "#dfbfff", # Number (magenta_1)
|
|
112
|
+
"success": "#d5ff80", # String (green_1)
|
|
113
|
+
"warning": "#ffd173", # Name.Function (yellow_1)
|
|
114
|
+
"error": "#f88f7f", # Error (red_1)
|
|
115
|
+
"info": "#95e6cb", # Name.Entity (cyan_1)
|
|
116
|
+
"muted": "dim",
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_user_theme_config() -> dict:
|
|
122
|
+
"""Load theme configuration from user config directory."""
|
|
123
|
+
cfg_dir = user_config_dir("sqlsaber")
|
|
124
|
+
path = os.path.join(cfg_dir, "theme.toml")
|
|
125
|
+
if not os.path.exists(path):
|
|
126
|
+
return {}
|
|
127
|
+
with open(path, "rb") as f:
|
|
128
|
+
return tomllib.load(f)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_refs(palette: dict[str, str]) -> dict[str, str]:
|
|
132
|
+
"""Resolve $var references in palette values."""
|
|
133
|
+
out = {}
|
|
134
|
+
for k, v in palette.items():
|
|
135
|
+
if isinstance(v, str) and "$" in v:
|
|
136
|
+
parts = v.split()
|
|
137
|
+
resolved = []
|
|
138
|
+
for part in parts:
|
|
139
|
+
if part.startswith("$"):
|
|
140
|
+
ref = part[1:]
|
|
141
|
+
resolved.append(palette.get(ref, ""))
|
|
142
|
+
else:
|
|
143
|
+
resolved.append(part)
|
|
144
|
+
out[k] = " ".join(p for p in resolved if p)
|
|
145
|
+
else:
|
|
146
|
+
out[k] = v
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class ThemeConfig:
|
|
152
|
+
"""Theme configuration."""
|
|
153
|
+
|
|
154
|
+
name: str
|
|
155
|
+
pygments_style: str
|
|
156
|
+
roles: Dict[str, str]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ThemeManager:
|
|
160
|
+
"""Manages theme configuration and provides themed components."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, cfg: ThemeConfig):
|
|
163
|
+
self._cfg = cfg
|
|
164
|
+
self._roles = _resolve_refs({**DEFAULT_ROLE_PALETTE, **cfg.roles})
|
|
165
|
+
self._rich_theme = Theme(self._roles)
|
|
166
|
+
self._pt_style = None
|
|
167
|
+
|
|
168
|
+
@property
|
|
169
|
+
def rich_theme(self) -> Theme:
|
|
170
|
+
"""Get Rich theme with semantic role mappings."""
|
|
171
|
+
return self._rich_theme
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def pygments_style_name(self) -> str:
|
|
175
|
+
"""Get pygments style name for syntax highlighting."""
|
|
176
|
+
return self._cfg.pygments_style
|
|
177
|
+
|
|
178
|
+
def pt_style(self) -> PTStyle:
|
|
179
|
+
"""Get prompt_toolkit style derived from Pygments theme."""
|
|
180
|
+
if self._pt_style is None:
|
|
181
|
+
try:
|
|
182
|
+
# Try to use Pygments style directly
|
|
183
|
+
pygments_style = get_style_by_name(self._cfg.pygments_style)
|
|
184
|
+
self._pt_style = style_from_pygments_cls(pygments_style)
|
|
185
|
+
except Exception:
|
|
186
|
+
# Fallback to basic style if Pygments theme not found
|
|
187
|
+
self._pt_style = PTStyle.from_dict({})
|
|
188
|
+
return self._pt_style
|
|
189
|
+
|
|
190
|
+
def style(self, role: str) -> str:
|
|
191
|
+
"""Get style string for a semantic role."""
|
|
192
|
+
return self._roles.get(role, "")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@lru_cache(maxsize=1)
|
|
196
|
+
def get_theme_manager() -> ThemeManager:
|
|
197
|
+
"""Get the global theme manager instance."""
|
|
198
|
+
user_cfg = _load_user_theme_config()
|
|
199
|
+
env_name = os.getenv("SQLSABER_THEME")
|
|
200
|
+
|
|
201
|
+
name = (
|
|
202
|
+
env_name or user_cfg.get("theme", {}).get("name") or DEFAULT_THEME_NAME
|
|
203
|
+
).lower()
|
|
204
|
+
pygments_style = user_cfg.get("theme", {}).get("pygments_style") or name
|
|
205
|
+
|
|
206
|
+
roles = dict(DEFAULT_ROLE_PALETTE)
|
|
207
|
+
roles.update(THEME_PRESETS.get(name, {}))
|
|
208
|
+
roles.update(user_cfg.get("roles", {}))
|
|
209
|
+
|
|
210
|
+
cfg = ThemeConfig(name=name, pygments_style=pygments_style, roles=roles)
|
|
211
|
+
return ThemeManager(cfg)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def create_console(**kwargs):
|
|
215
|
+
"""Create a Rich Console with theme applied."""
|
|
216
|
+
# from rich.console import Console
|
|
217
|
+
|
|
218
|
+
tm = get_theme_manager()
|
|
219
|
+
return Console(theme=tm.rich_theme, **kwargs)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""SQL query validation and security using sqlglot AST analysis."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import sqlglot
|
|
7
|
+
from sqlglot import exp
|
|
8
|
+
from sqlglot.errors import ParseError
|
|
9
|
+
|
|
10
|
+
# Prohibited AST node types that indicate write/mutation operations
|
|
11
|
+
# Only include expression types that exist in sqlglot
|
|
12
|
+
PROHIBITED_NODES = {
|
|
13
|
+
# DML operations
|
|
14
|
+
exp.Insert,
|
|
15
|
+
exp.Update,
|
|
16
|
+
exp.Delete,
|
|
17
|
+
exp.Merge,
|
|
18
|
+
# DDL operations
|
|
19
|
+
exp.Create,
|
|
20
|
+
exp.Drop,
|
|
21
|
+
exp.Alter,
|
|
22
|
+
exp.TruncateTable,
|
|
23
|
+
exp.AlterRename,
|
|
24
|
+
# MySQL specific
|
|
25
|
+
exp.Replace,
|
|
26
|
+
# Transaction control
|
|
27
|
+
exp.Transaction,
|
|
28
|
+
# Analysis and maintenance
|
|
29
|
+
exp.Analyze,
|
|
30
|
+
# Data loading/copying
|
|
31
|
+
exp.Copy,
|
|
32
|
+
exp.LoadData,
|
|
33
|
+
# Session and configuration
|
|
34
|
+
exp.Set,
|
|
35
|
+
exp.Use,
|
|
36
|
+
exp.Pragma,
|
|
37
|
+
# Security
|
|
38
|
+
exp.Grant,
|
|
39
|
+
exp.Revoke,
|
|
40
|
+
# Database operations
|
|
41
|
+
exp.Attach,
|
|
42
|
+
exp.Detach,
|
|
43
|
+
# Locking and process control
|
|
44
|
+
exp.Lock,
|
|
45
|
+
exp.Kill,
|
|
46
|
+
# Commands
|
|
47
|
+
exp.Command,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
# Add optional types that may not exist in all sqlglot versions
|
|
52
|
+
PROHIBITED_NODES.add(exp.Vacuum)
|
|
53
|
+
except AttributeError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
# Dangerous functions by dialect that can read files or execute commands
|
|
57
|
+
DANGEROUS_FUNCTIONS_BY_DIALECT = {
|
|
58
|
+
"postgres": {
|
|
59
|
+
"pg_read_file",
|
|
60
|
+
"pg_read_binary_file",
|
|
61
|
+
"pg_ls_dir",
|
|
62
|
+
"pg_stat_file",
|
|
63
|
+
"pg_logdir_ls",
|
|
64
|
+
"dblink",
|
|
65
|
+
"dblink_exec",
|
|
66
|
+
},
|
|
67
|
+
"mysql": {
|
|
68
|
+
"load_file",
|
|
69
|
+
"sys_eval",
|
|
70
|
+
"sys_exec",
|
|
71
|
+
},
|
|
72
|
+
"sqlite": {
|
|
73
|
+
"readfile",
|
|
74
|
+
"writefile",
|
|
75
|
+
},
|
|
76
|
+
"tsql": {
|
|
77
|
+
"xp_cmdshell",
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class GuardResult:
|
|
84
|
+
"""Result of SQL query validation."""
|
|
85
|
+
|
|
86
|
+
allowed: bool
|
|
87
|
+
reason: Optional[str] = None
|
|
88
|
+
is_select: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def is_select_like(stmt: exp.Expression) -> bool:
|
|
92
|
+
"""Check if statement is a SELECT-like query.
|
|
93
|
+
|
|
94
|
+
Handles CTEs (WITH) and set operations (UNION/INTERSECT/EXCEPT).
|
|
95
|
+
"""
|
|
96
|
+
root = stmt
|
|
97
|
+
# WITH wraps another statement
|
|
98
|
+
if isinstance(root, exp.With):
|
|
99
|
+
root = root.this
|
|
100
|
+
return isinstance(root, (exp.Select, exp.Union, exp.Except, exp.Intersect))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def has_prohibited_nodes(stmt: exp.Expression) -> Optional[str]:
|
|
104
|
+
"""Walk AST to find any prohibited operations.
|
|
105
|
+
|
|
106
|
+
Checks for:
|
|
107
|
+
- Write operations (INSERT/UPDATE/DELETE/etc)
|
|
108
|
+
- DDL operations (CREATE/DROP/ALTER/etc)
|
|
109
|
+
- SELECT INTO
|
|
110
|
+
- Locking clauses (FOR UPDATE/FOR SHARE)
|
|
111
|
+
"""
|
|
112
|
+
for node in stmt.walk():
|
|
113
|
+
# Check prohibited node types
|
|
114
|
+
if isinstance(node, tuple(PROHIBITED_NODES)):
|
|
115
|
+
return f"Prohibited operation: {type(node).__name__}"
|
|
116
|
+
|
|
117
|
+
# Block SELECT INTO (Postgres-style table creation)
|
|
118
|
+
if isinstance(node, exp.Select) and node.args.get("into"):
|
|
119
|
+
return "SELECT INTO is not allowed"
|
|
120
|
+
|
|
121
|
+
# Block locking clauses (FOR UPDATE/FOR SHARE)
|
|
122
|
+
if isinstance(node, exp.Select):
|
|
123
|
+
locks = node.args.get("locks")
|
|
124
|
+
if locks:
|
|
125
|
+
return "SELECT with locking clause (FOR UPDATE/SHARE) is not allowed"
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def has_dangerous_functions(stmt: exp.Expression, dialect: str) -> Optional[str]:
|
|
131
|
+
"""Check for dangerous functions that can read files or execute commands."""
|
|
132
|
+
deny_set = DANGEROUS_FUNCTIONS_BY_DIALECT.get(dialect, set())
|
|
133
|
+
if not deny_set:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
deny_lower = {f.lower() for f in deny_set}
|
|
137
|
+
|
|
138
|
+
for fn in stmt.find_all(exp.Func):
|
|
139
|
+
name = fn.name
|
|
140
|
+
if name and name.lower() in deny_lower:
|
|
141
|
+
return f"Use of dangerous function '{name}' is not allowed"
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def validate_read_only(sql: str, dialect: str = "ansi") -> GuardResult:
|
|
147
|
+
"""Validate that SQL query is read-only using AST analysis.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
sql: SQL query to validate
|
|
151
|
+
dialect: SQL dialect (postgres, mysql, sqlite, tsql, etc.)
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
GuardResult with validation outcome
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
statements = sqlglot.parse(sql, read=dialect)
|
|
158
|
+
except ParseError as e:
|
|
159
|
+
return GuardResult(False, f"Unable to parse query safely: {e}")
|
|
160
|
+
except Exception as e:
|
|
161
|
+
return GuardResult(False, f"Error parsing query: {e}")
|
|
162
|
+
|
|
163
|
+
# Only allow single statements
|
|
164
|
+
if len(statements) != 1:
|
|
165
|
+
return GuardResult(
|
|
166
|
+
False,
|
|
167
|
+
f"Only single SELECT statements are allowed (got {len(statements)} statements)",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
stmt = statements[0]
|
|
171
|
+
|
|
172
|
+
# Must be a SELECT-like statement
|
|
173
|
+
if not is_select_like(stmt):
|
|
174
|
+
return GuardResult(False, "Only SELECT-like statements are allowed")
|
|
175
|
+
|
|
176
|
+
# Check for prohibited operations in the AST
|
|
177
|
+
reason = has_prohibited_nodes(stmt)
|
|
178
|
+
if reason:
|
|
179
|
+
return GuardResult(False, reason)
|
|
180
|
+
|
|
181
|
+
# Check for dangerous functions
|
|
182
|
+
reason = has_dangerous_functions(stmt, dialect)
|
|
183
|
+
if reason:
|
|
184
|
+
return GuardResult(False, reason)
|
|
185
|
+
|
|
186
|
+
return GuardResult(True, None, is_select=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def add_limit(sql: str, dialect: str = "ansi", limit: int = 100) -> str:
|
|
190
|
+
"""Add LIMIT clause to query if not already present.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
sql: SQL query
|
|
194
|
+
dialect: SQL dialect for proper rendering
|
|
195
|
+
limit: Maximum number of rows to return
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
SQL with LIMIT clause added (or original if LIMIT already exists)
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
statements = sqlglot.parse(sql, read=dialect)
|
|
202
|
+
if len(statements) != 1:
|
|
203
|
+
return sql
|
|
204
|
+
|
|
205
|
+
stmt = statements[0]
|
|
206
|
+
|
|
207
|
+
# Check if LIMIT/TOP/FETCH already exists
|
|
208
|
+
has_limit = any(
|
|
209
|
+
isinstance(n, (exp.Limit, exp.Top, exp.Fetch)) for n in stmt.walk()
|
|
210
|
+
)
|
|
211
|
+
if has_limit:
|
|
212
|
+
return stmt.sql(dialect=dialect)
|
|
213
|
+
|
|
214
|
+
# Add LIMIT - sqlglot will render appropriately for dialect
|
|
215
|
+
# (LIMIT for most, TOP for SQL Server, FETCH FIRST for Oracle)
|
|
216
|
+
stmt = stmt.limit(limit)
|
|
217
|
+
return stmt.sql(dialect=dialect)
|
|
218
|
+
|
|
219
|
+
except Exception:
|
|
220
|
+
# If parsing/transformation fails, fall back to simple string append
|
|
221
|
+
# This maintains backward compatibility
|
|
222
|
+
sql_upper = sql.strip().upper()
|
|
223
|
+
if "LIMIT" not in sql_upper:
|
|
224
|
+
return f"{sql.rstrip(';')} LIMIT {limit};"
|
|
225
|
+
return sql
|
sqlsaber/tools/sql_tools.py
CHANGED
|
@@ -9,6 +9,7 @@ from sqlsaber.database.schema import SchemaManager
|
|
|
9
9
|
from .base import Tool
|
|
10
10
|
from .enums import ToolCategory, WorkflowPosition
|
|
11
11
|
from .registry import register_tool
|
|
12
|
+
from .sql_guard import add_limit, validate_read_only
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class SQLTool(Tool):
|
|
@@ -207,13 +208,17 @@ class ExecuteSQLTool(SQLTool):
|
|
|
207
208
|
limit = kwargs.get("limit", self.DEFAULT_LIMIT)
|
|
208
209
|
|
|
209
210
|
try:
|
|
210
|
-
#
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
# Get the dialect for this database
|
|
212
|
+
dialect = self.db.sqlglot_dialect
|
|
213
|
+
|
|
214
|
+
# Security check using sqlglot AST analysis
|
|
215
|
+
validation_result = validate_read_only(query, dialect)
|
|
216
|
+
if not validation_result.allowed:
|
|
217
|
+
return json.dumps({"error": validation_result.reason})
|
|
214
218
|
|
|
215
219
|
# Add LIMIT if not present and it's a SELECT query
|
|
216
|
-
|
|
220
|
+
if validation_result.is_select and limit:
|
|
221
|
+
query = add_limit(query, dialect, limit)
|
|
217
222
|
|
|
218
223
|
# Execute the query
|
|
219
224
|
results = await self.db.execute_query(query)
|
|
@@ -249,33 +254,3 @@ class ExecuteSQLTool(SQLTool):
|
|
|
249
254
|
)
|
|
250
255
|
|
|
251
256
|
return json.dumps({"error": error_msg, "suggestions": suggestions})
|
|
252
|
-
|
|
253
|
-
def _validate_write_operation(self, query: str) -> str | None:
|
|
254
|
-
"""Validate if a write operation is allowed."""
|
|
255
|
-
query_upper = query.strip().upper()
|
|
256
|
-
|
|
257
|
-
# Check for write operations
|
|
258
|
-
write_keywords = [
|
|
259
|
-
"INSERT",
|
|
260
|
-
"UPDATE",
|
|
261
|
-
"DELETE",
|
|
262
|
-
"DROP",
|
|
263
|
-
"CREATE",
|
|
264
|
-
"ALTER",
|
|
265
|
-
"TRUNCATE",
|
|
266
|
-
]
|
|
267
|
-
is_write_query = any(query_upper.startswith(kw) for kw in write_keywords)
|
|
268
|
-
|
|
269
|
-
if is_write_query:
|
|
270
|
-
return (
|
|
271
|
-
"Write operations are not allowed. Only SELECT queries are permitted."
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
return None
|
|
275
|
-
|
|
276
|
-
def _add_limit_to_query(self, query: str, limit: int = 100) -> str:
|
|
277
|
-
"""Add LIMIT clause to SELECT queries if not present."""
|
|
278
|
-
query_upper = query.strip().upper()
|
|
279
|
-
if query_upper.startswith("SELECT") and "LIMIT" not in query_upper:
|
|
280
|
-
return f"{query.rstrip(';')} LIMIT {limit};"
|
|
281
|
-
return query
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlsaber
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: SQLsaber - Open-source agentic SQL assistant
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -17,6 +17,7 @@ Requires-Dist: prompt-toolkit>3.0.51
|
|
|
17
17
|
Requires-Dist: pydantic-ai
|
|
18
18
|
Requires-Dist: questionary>=2.1.0
|
|
19
19
|
Requires-Dist: rich>=13.7.0
|
|
20
|
+
Requires-Dist: sqlglot[rs]>=27.20.0
|
|
20
21
|
Description-Content-Type: text/markdown
|
|
21
22
|
|
|
22
23
|
# SQLsaber
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
sqlsaber/__init__.py,sha256=HjS8ULtP4MGpnTL7njVY45NKV9Fi4e_yeYuY-hyXWQc,73
|
|
2
|
+
sqlsaber/__main__.py,sha256=RIHxWeWh2QvLfah-2OkhI5IJxojWfy4fXpMnVEJYvxw,78
|
|
3
|
+
sqlsaber/agents/__init__.py,sha256=qYI6rLY4q5AbF47vXH5RVoM08-yQjymBSaePh4lFIW4,116
|
|
4
|
+
sqlsaber/agents/base.py,sha256=40-MKEoz5rGrqVIylV1U2DaAUSPFcC75ohRin4E3-kk,2668
|
|
5
|
+
sqlsaber/agents/mcp.py,sha256=Pn8tdDRUEVLYQyEi5nHRp9MKNePwHVVoeNI-uqWcr0Y,757
|
|
6
|
+
sqlsaber/agents/pydantic_ai_agent.py,sha256=wBxKz0pjOkL-HI-TXV6B67bczZNgu7k26Rr3w5usR3o,10064
|
|
7
|
+
sqlsaber/application/__init__.py,sha256=KY_-d5nEdQyAwNOsK5r-f7Tb69c63XbuEkHPeLpJal8,84
|
|
8
|
+
sqlsaber/application/auth_setup.py,sha256=cbS2VTwL7mwm24UFRz84PhC13XMzC1F7JkN-ze7tApY,5104
|
|
9
|
+
sqlsaber/application/db_setup.py,sha256=qtMxCd_KO7GsD4W_iRBpDRvLriiyvOXPvZdcvm6KVDM,6849
|
|
10
|
+
sqlsaber/application/model_selection.py,sha256=xZI-nvUgYZikaTK38SCmEWvWSfRsDpFu2VthbVdI95g,3187
|
|
11
|
+
sqlsaber/application/prompts.py,sha256=4rMGcWpYJbNWPMzqVWseUMx0nwvXOkWS6GaTAJ5mhfc,3473
|
|
12
|
+
sqlsaber/cli/__init__.py,sha256=qVSLVJLLJYzoC6aj6y9MFrzZvAwc4_OgxU9DlkQnZ4M,86
|
|
13
|
+
sqlsaber/cli/auth.py,sha256=ysDBXEFR8Jz7wYbIP6X7yWA2ivd8SDnUp_jUg_qYNWk,6088
|
|
14
|
+
sqlsaber/cli/commands.py,sha256=-rTxr-kW7j2rR8wAg0tATKoh284pMDPKVMpQKaJwtqk,8540
|
|
15
|
+
sqlsaber/cli/completers.py,sha256=g-hLDq5fiBx7gg8Bte1Lq8GU-ZxCYVs4dcPsmHPIcK4,6574
|
|
16
|
+
sqlsaber/cli/database.py,sha256=hh8PdWnhaD0fO2jwvSSQyxsjwk-JyvmcY7f5tuHfnAQ,10663
|
|
17
|
+
sqlsaber/cli/display.py,sha256=WB5JCumhXadziDEX1EZHG3vN1Chol5FNAaTXHieqFK0,17892
|
|
18
|
+
sqlsaber/cli/interactive.py,sha256=u1gvdMZJkFWelZQ2urOS6-EH1uShKF4u_edfr_BzDNk,13479
|
|
19
|
+
sqlsaber/cli/memory.py,sha256=gKP-JJ0w1ya1YTM_Lk7Gw-7wL9ptyj6cZtg-uoW8K7A,7818
|
|
20
|
+
sqlsaber/cli/models.py,sha256=NozZbnisSjbPKo7PW7CltJMIkGcPqTDpDQEY-C_eLhk,8504
|
|
21
|
+
sqlsaber/cli/onboarding.py,sha256=l6FFWn8J1OVQUxr-xIAzKaFhAz8rFh6IEWwIyPWqR6U,11438
|
|
22
|
+
sqlsaber/cli/streaming.py,sha256=1XoZGPPMoTmBQVgp_Bqk483MR93j9oXxSV6Tx_-TpOg,6923
|
|
23
|
+
sqlsaber/cli/threads.py,sha256=5EV4ckRzKqhWeTKpTfQSNCBuqs3onsJURKT09g4E4XM,13634
|
|
24
|
+
sqlsaber/config/__init__.py,sha256=olwC45k8Nc61yK0WmPUk7XHdbsZH9HuUAbwnmKe3IgA,100
|
|
25
|
+
sqlsaber/config/api_keys.py,sha256=bjogRmIuxNNGusyKXKi0ZpJWeS5Fyn53zrAD8hsoYx4,3671
|
|
26
|
+
sqlsaber/config/auth.py,sha256=b5qB2h1doXyO9Bn8z0CcL8LAR2jF431gGXBGKLgTmtQ,2756
|
|
27
|
+
sqlsaber/config/database.py,sha256=Yec6_0wdzq-ADblMNnbgvouYCimYOY_DWHT9oweaISc,11449
|
|
28
|
+
sqlsaber/config/oauth_flow.py,sha256=VNbq4TZPk0hVJIcOh7JUO5qSxJnNzqDj0vwjCn-59Ok,10316
|
|
29
|
+
sqlsaber/config/oauth_tokens.py,sha256=SC-lXVcKCV7uiWtBiU2mxvx1z7ryW8tOSKHBApPsXtE,5931
|
|
30
|
+
sqlsaber/config/providers.py,sha256=JFjeJv1K5Q93zWSlWq3hAvgch1TlgoF0qFa0KJROkKY,2957
|
|
31
|
+
sqlsaber/config/settings.py,sha256=iB4CnGQ4hw8gxkaa9CVLB_JEy6Y9h9FQTAams5OCVyI,6421
|
|
32
|
+
sqlsaber/database/__init__.py,sha256=Gi9N_NOkD459WRWXDg3hSuGoBs3xWbMDRBvsTVmnGAg,2025
|
|
33
|
+
sqlsaber/database/base.py,sha256=oaipLxlvoylX6oJCITPAWWqRqv09hRELqqEBufsmFic,3703
|
|
34
|
+
sqlsaber/database/csv.py,sha256=41wuP40FaGPfj28HMiD0I69uG0JbUxArpoTLC3MG2uc,4464
|
|
35
|
+
sqlsaber/database/duckdb.py,sha256=8HNKdx208aFK_YtwGjLz6LTne0xEmNevD-f9dRWlrFg,11244
|
|
36
|
+
sqlsaber/database/mysql.py,sha256=wMzDQqq4GFbfEdqXtv_sCb4Qbr9GSWqYAvOLeo5UryY,14472
|
|
37
|
+
sqlsaber/database/postgresql.py,sha256=fuf2Wl29NKXvD3mqsR08PDleNQ1PG-fNvWSxT6HDh2M,13223
|
|
38
|
+
sqlsaber/database/resolver.py,sha256=wSCcn__aCqwIfpt_LCjtW2Zgb8RpG5PlmwwZHli1q_U,3628
|
|
39
|
+
sqlsaber/database/schema.py,sha256=68PrNcA-5eR9PZB3i-TUQw5_E7QatwiDU2wv9GgXgM4,6928
|
|
40
|
+
sqlsaber/database/sqlite.py,sha256=iReEIiSpkhhS1VzITd79ZWqSL3fHMyfe3DRCDpM0DvE,9421
|
|
41
|
+
sqlsaber/mcp/__init__.py,sha256=COdWq7wauPBp5Ew8tfZItFzbcLDSEkHBJSMhxzy8C9c,112
|
|
42
|
+
sqlsaber/mcp/mcp.py,sha256=tpNPHpkaCre1Xjp7c4DHXbTKeuYpDQ8qhmJZvAyr7Vk,3939
|
|
43
|
+
sqlsaber/memory/__init__.py,sha256=GiWkU6f6YYVV0EvvXDmFWe_CxarmDCql05t70MkTEWs,63
|
|
44
|
+
sqlsaber/memory/manager.py,sha256=p3fybMVfH-E4ApT1ZRZUnQIWSk9dkfUPCyfkmA0HALs,2739
|
|
45
|
+
sqlsaber/memory/storage.py,sha256=ne8szLlGj5NELheqLnI7zu21V8YS4rtpYGGC7tOmi-s,5745
|
|
46
|
+
sqlsaber/theme/__init__.py,sha256=qCICX1Cg4B6yCbZ1UrerxglWxcqldRFVSRrSs73na_8,188
|
|
47
|
+
sqlsaber/theme/manager.py,sha256=0DWuVXn7JoC8NvAl5FSqc61eagKFTx5YnoY8SoCTxGM,7236
|
|
48
|
+
sqlsaber/threads/__init__.py,sha256=Hh3dIG1tuC8fXprREUpslCIgPYz8_6o7aRLx4yNeO48,139
|
|
49
|
+
sqlsaber/threads/storage.py,sha256=rsUdxT4CR52D7xtGir9UlsFnBMk11jZeflzDrk2q4ME,11183
|
|
50
|
+
sqlsaber/tools/__init__.py,sha256=x3YdmX_7P0Qq_HtZHAgfIVKTLxYqKk6oc4tGsujQWsc,586
|
|
51
|
+
sqlsaber/tools/base.py,sha256=mHhvAj27BHmckyvuDLCPlAQdzABJyYxd9SJnaYAwwuA,1777
|
|
52
|
+
sqlsaber/tools/enums.py,sha256=CH32mL-0k9ZA18911xLpNtsgpV6tB85TktMj6uqGz54,411
|
|
53
|
+
sqlsaber/tools/instructions.py,sha256=X-x8maVkkyi16b6Tl0hcAFgjiYceZaSwyWTfmrvx8U8,9024
|
|
54
|
+
sqlsaber/tools/registry.py,sha256=HWOQMsNIdL4XZS6TeNUyrL-5KoSDH6PHsWd3X66o-18,3211
|
|
55
|
+
sqlsaber/tools/sql_guard.py,sha256=dTDwcZP-N4xPGzcr7MQtKUxKrlDzlc1irr9aH5a4wvk,6182
|
|
56
|
+
sqlsaber/tools/sql_tools.py,sha256=ujmAcfLkNaBrb5LWEgWcINQEQSX0LRPX3VK5Dag1Sj4,9178
|
|
57
|
+
sqlsaber-0.28.0.dist-info/METADATA,sha256=oY9Awl9jLkdeJLa1oeTPHPGH94KktJ_HmBVUVLQs-do,7174
|
|
58
|
+
sqlsaber-0.28.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
59
|
+
sqlsaber-0.28.0.dist-info/entry_points.txt,sha256=qEbOB7OffXPFgyJc7qEIJlMEX5RN9xdzLmWZa91zCQQ,162
|
|
60
|
+
sqlsaber-0.28.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
61
|
+
sqlsaber-0.28.0.dist-info/RECORD,,
|
sqlsaber-0.26.0.dist-info/RECORD
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
sqlsaber/__init__.py,sha256=HjS8ULtP4MGpnTL7njVY45NKV9Fi4e_yeYuY-hyXWQc,73
|
|
2
|
-
sqlsaber/__main__.py,sha256=RIHxWeWh2QvLfah-2OkhI5IJxojWfy4fXpMnVEJYvxw,78
|
|
3
|
-
sqlsaber/agents/__init__.py,sha256=qYI6rLY4q5AbF47vXH5RVoM08-yQjymBSaePh4lFIW4,116
|
|
4
|
-
sqlsaber/agents/base.py,sha256=40-MKEoz5rGrqVIylV1U2DaAUSPFcC75ohRin4E3-kk,2668
|
|
5
|
-
sqlsaber/agents/mcp.py,sha256=Pn8tdDRUEVLYQyEi5nHRp9MKNePwHVVoeNI-uqWcr0Y,757
|
|
6
|
-
sqlsaber/agents/pydantic_ai_agent.py,sha256=wBxKz0pjOkL-HI-TXV6B67bczZNgu7k26Rr3w5usR3o,10064
|
|
7
|
-
sqlsaber/cli/__init__.py,sha256=qVSLVJLLJYzoC6aj6y9MFrzZvAwc4_OgxU9DlkQnZ4M,86
|
|
8
|
-
sqlsaber/cli/auth.py,sha256=jTsRgbmlGPlASSuIKmdjjwfqtKvjfKd_cTYxX0-QqaQ,7400
|
|
9
|
-
sqlsaber/cli/commands.py,sha256=n25CErTLgLeRSkoJI0Ickwtns5EH6O7RLVPgPs6UBxA,7986
|
|
10
|
-
sqlsaber/cli/completers.py,sha256=g-hLDq5fiBx7gg8Bte1Lq8GU-ZxCYVs4dcPsmHPIcK4,6574
|
|
11
|
-
sqlsaber/cli/database.py,sha256=qil7nZGWKm3tULL0cUsAQ_KvhU1oikK0XVh9MibrvP0,13413
|
|
12
|
-
sqlsaber/cli/display.py,sha256=32QaNS0RDgRz93AVy6nPo9blahvMPEoVMFC5spzh0-Y,17041
|
|
13
|
-
sqlsaber/cli/interactive.py,sha256=jGbWNNcEgZuQRZamc5tX5eIf1Rv1T6Sj5NI_WvonTrA,13624
|
|
14
|
-
sqlsaber/cli/memory.py,sha256=OufHFJFwV0_GGn7LvKRTJikkWhV1IwNIUDOxFPHXOaQ,7794
|
|
15
|
-
sqlsaber/cli/models.py,sha256=ZewtwGQwhd9b-yxBAPKePolvI1qQG-EkmeWAGMqtWNQ,8986
|
|
16
|
-
sqlsaber/cli/streaming.py,sha256=YViLCxUv-7WN5TCphLYtAR02HXvuHYuPttGGDZKDUKU,6921
|
|
17
|
-
sqlsaber/cli/threads.py,sha256=zVlbOuD3GjjEVNebXwANKeKt4I_Lunf6itiBUL0TaKA,12877
|
|
18
|
-
sqlsaber/config/__init__.py,sha256=olwC45k8Nc61yK0WmPUk7XHdbsZH9HuUAbwnmKe3IgA,100
|
|
19
|
-
sqlsaber/config/api_keys.py,sha256=RqWQCko1tY7sES7YOlexgBH5Hd5ne_kGXHdBDNqcV2U,3649
|
|
20
|
-
sqlsaber/config/auth.py,sha256=b5qB2h1doXyO9Bn8z0CcL8LAR2jF431gGXBGKLgTmtQ,2756
|
|
21
|
-
sqlsaber/config/database.py,sha256=Yec6_0wdzq-ADblMNnbgvouYCimYOY_DWHT9oweaISc,11449
|
|
22
|
-
sqlsaber/config/oauth_flow.py,sha256=A3bSXaBLzuAfXV2ZPA94m9NV33c2MyL6M4ii9oEkswQ,10291
|
|
23
|
-
sqlsaber/config/oauth_tokens.py,sha256=C9z35hyx-PvSAYdC1LNf3rg9_wsEIY56hkEczelbad0,6015
|
|
24
|
-
sqlsaber/config/providers.py,sha256=JFjeJv1K5Q93zWSlWq3hAvgch1TlgoF0qFa0KJROkKY,2957
|
|
25
|
-
sqlsaber/config/settings.py,sha256=iB4CnGQ4hw8gxkaa9CVLB_JEy6Y9h9FQTAams5OCVyI,6421
|
|
26
|
-
sqlsaber/database/__init__.py,sha256=Gi9N_NOkD459WRWXDg3hSuGoBs3xWbMDRBvsTVmnGAg,2025
|
|
27
|
-
sqlsaber/database/base.py,sha256=yxYcfeNhRPbO5jFRVZH7eRUGj_up-y3p1ZX_obZXi0w,3552
|
|
28
|
-
sqlsaber/database/csv.py,sha256=45eH9mAkBtwSu1Rc_vvG1Z40L4xvfHWSb8OMG15TbCA,4340
|
|
29
|
-
sqlsaber/database/duckdb.py,sha256=v6gFUhih5NMbHHpUv7By2nXyl9aqdPtLt0zhqS4-OKE,11120
|
|
30
|
-
sqlsaber/database/mysql.py,sha256=5qd9gnSCP3umtBJcQDTzzJfMzwqYCJhWlbOeJZ9_-6c,14349
|
|
31
|
-
sqlsaber/database/postgresql.py,sha256=R8I3Y-w0P9qPe47-lmae0X17syIwI8saxEG3etx6Rqc,13097
|
|
32
|
-
sqlsaber/database/resolver.py,sha256=wSCcn__aCqwIfpt_LCjtW2Zgb8RpG5PlmwwZHli1q_U,3628
|
|
33
|
-
sqlsaber/database/schema.py,sha256=68PrNcA-5eR9PZB3i-TUQw5_E7QatwiDU2wv9GgXgM4,6928
|
|
34
|
-
sqlsaber/database/sqlite.py,sha256=zdNj5i4mLJK21sWgftAHDHVihRUWevn__tVF9_nnLfQ,9297
|
|
35
|
-
sqlsaber/mcp/__init__.py,sha256=COdWq7wauPBp5Ew8tfZItFzbcLDSEkHBJSMhxzy8C9c,112
|
|
36
|
-
sqlsaber/mcp/mcp.py,sha256=tpNPHpkaCre1Xjp7c4DHXbTKeuYpDQ8qhmJZvAyr7Vk,3939
|
|
37
|
-
sqlsaber/memory/__init__.py,sha256=GiWkU6f6YYVV0EvvXDmFWe_CxarmDCql05t70MkTEWs,63
|
|
38
|
-
sqlsaber/memory/manager.py,sha256=p3fybMVfH-E4ApT1ZRZUnQIWSk9dkfUPCyfkmA0HALs,2739
|
|
39
|
-
sqlsaber/memory/storage.py,sha256=ne8szLlGj5NELheqLnI7zu21V8YS4rtpYGGC7tOmi-s,5745
|
|
40
|
-
sqlsaber/threads/__init__.py,sha256=Hh3dIG1tuC8fXprREUpslCIgPYz8_6o7aRLx4yNeO48,139
|
|
41
|
-
sqlsaber/threads/storage.py,sha256=rsUdxT4CR52D7xtGir9UlsFnBMk11jZeflzDrk2q4ME,11183
|
|
42
|
-
sqlsaber/tools/__init__.py,sha256=x3YdmX_7P0Qq_HtZHAgfIVKTLxYqKk6oc4tGsujQWsc,586
|
|
43
|
-
sqlsaber/tools/base.py,sha256=mHhvAj27BHmckyvuDLCPlAQdzABJyYxd9SJnaYAwwuA,1777
|
|
44
|
-
sqlsaber/tools/enums.py,sha256=CH32mL-0k9ZA18911xLpNtsgpV6tB85TktMj6uqGz54,411
|
|
45
|
-
sqlsaber/tools/instructions.py,sha256=X-x8maVkkyi16b6Tl0hcAFgjiYceZaSwyWTfmrvx8U8,9024
|
|
46
|
-
sqlsaber/tools/registry.py,sha256=HWOQMsNIdL4XZS6TeNUyrL-5KoSDH6PHsWd3X66o-18,3211
|
|
47
|
-
sqlsaber/tools/sql_tools.py,sha256=2xLD_pkd0t8wKndQAKIr4c9UpWzVWeHbAFpkwo5j4kY,9954
|
|
48
|
-
sqlsaber-0.26.0.dist-info/METADATA,sha256=o4vaJVAG_1U5Tybcx2MY3lX0FvYjBEdKxFDWhFC9xYs,7138
|
|
49
|
-
sqlsaber-0.26.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
50
|
-
sqlsaber-0.26.0.dist-info/entry_points.txt,sha256=qEbOB7OffXPFgyJc7qEIJlMEX5RN9xdzLmWZa91zCQQ,162
|
|
51
|
-
sqlsaber-0.26.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
52
|
-
sqlsaber-0.26.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|