sqlnbfmt 0.1.1__tar.gz → 0.1.2__tar.gz
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.
- {sqlnbfmt-0.1.1/sqlnbfmt.egg-info → sqlnbfmt-0.1.2}/PKG-INFO +1 -1
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/pyproject.toml +1 -1
- sqlnbfmt-0.1.2/sqlnbfmt/formatter.py +503 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2/sqlnbfmt.egg-info}/PKG-INFO +1 -1
- sqlnbfmt-0.1.2/tests/test_formatter.py +143 -0
- sqlnbfmt-0.1.1/sqlnbfmt/formatter.py +0 -409
- sqlnbfmt-0.1.1/tests/test_formatter.py +0 -406
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/LICENSE +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/README.md +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/setup.cfg +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/sqlnbfmt/__init__.py +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/sqlnbfmt.egg-info/SOURCES.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/sqlnbfmt.egg-info/dependency_links.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/sqlnbfmt.egg-info/entry_points.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/sqlnbfmt.egg-info/requires.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.2}/sqlnbfmt.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Set, Optional, Dict, Union, Tuple
|
|
8
|
+
|
|
9
|
+
import astor
|
|
10
|
+
import nbformat
|
|
11
|
+
import yaml
|
|
12
|
+
from sqlglot import parse_one, errors
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FormattingConfig:
|
|
17
|
+
"""Configuration for SQL formatting."""
|
|
18
|
+
|
|
19
|
+
sql_keywords: Set[str]
|
|
20
|
+
function_names: Set[str]
|
|
21
|
+
sql_decorators: Set[str]
|
|
22
|
+
single_line_threshold: int = 80
|
|
23
|
+
preserve_comments: bool = True
|
|
24
|
+
indent_width: int = 4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SQLFormattingError(Exception):
|
|
28
|
+
"""Custom exception for SQL formatting errors."""
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def setup_logging(level: str = "INFO") -> logging.Logger:
|
|
34
|
+
"""Sets up logging with the specified level."""
|
|
35
|
+
logger = logging.getLogger('formatter')
|
|
36
|
+
logger.setLevel(level.upper())
|
|
37
|
+
if not logger.handlers:
|
|
38
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
39
|
+
formatter = logging.Formatter("[%(levelname)s] %(message)s")
|
|
40
|
+
handler.setFormatter(formatter)
|
|
41
|
+
logger.addHandler(handler)
|
|
42
|
+
return logger
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_config(config_path: Union[str, Path] = "config.yaml") -> FormattingConfig:
|
|
46
|
+
"""Loads configuration from a YAML file."""
|
|
47
|
+
config_file = Path(config_path)
|
|
48
|
+
if not config_file.is_file():
|
|
49
|
+
raise FileNotFoundError(f"Configuration file '{config_file}' not found.")
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
with open(config_file) as file:
|
|
53
|
+
config = yaml.safe_load(file)
|
|
54
|
+
return FormattingConfig(
|
|
55
|
+
sql_keywords=set(config.get("sql_keywords", [])),
|
|
56
|
+
function_names=set(config.get("function_names", [])),
|
|
57
|
+
sql_decorators=set(config.get("sql_decorators", [])),
|
|
58
|
+
single_line_threshold=config.get("formatting_options", {}).get(
|
|
59
|
+
"single_line_threshold", 80
|
|
60
|
+
),
|
|
61
|
+
preserve_comments=config.get("formatting_options", {}).get(
|
|
62
|
+
"preserve_comments", True
|
|
63
|
+
),
|
|
64
|
+
indent_width=config.get("formatting_options", {}).get(
|
|
65
|
+
"indent_width", 4
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
except yaml.YAMLError as e:
|
|
69
|
+
raise yaml.YAMLError(f"Error parsing the configuration file: {e}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_sql_code(
|
|
73
|
+
sql_code: str,
|
|
74
|
+
dialect: Optional[str],
|
|
75
|
+
config: FormattingConfig,
|
|
76
|
+
placeholders: Optional[Dict[str, str]] = None,
|
|
77
|
+
force_single_line: bool = False,
|
|
78
|
+
is_magic_command: bool = False,
|
|
79
|
+
is_cell_magic: bool = False,
|
|
80
|
+
) -> str:
|
|
81
|
+
"""
|
|
82
|
+
Formats SQL code using sqlglot's native formatting capabilities.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
sql_code (str): The original SQL code to format.
|
|
86
|
+
dialect (Optional[str]): The SQL dialect to use (e.g., 'postgres', 'mysql').
|
|
87
|
+
config (FormattingConfig): The formatting configuration.
|
|
88
|
+
placeholders (Optional[Dict[str, str]]): A mapping of placeholders to their expressions.
|
|
89
|
+
force_single_line (bool): Whether to force the formatted SQL into a single line.
|
|
90
|
+
is_magic_command (bool): Whether the SQL code comes from a magic command.
|
|
91
|
+
is_cell_magic (bool): Whether the SQL code comes from a cell magic command.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
str: The formatted SQL code.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
logger = logging.getLogger('formatter')
|
|
98
|
+
|
|
99
|
+
if not sql_code.strip():
|
|
100
|
+
return sql_code
|
|
101
|
+
|
|
102
|
+
temp_sql = sql_code
|
|
103
|
+
|
|
104
|
+
# Handle placeholders in f-strings
|
|
105
|
+
placeholder_mapping = {}
|
|
106
|
+
if placeholders:
|
|
107
|
+
for placeholder in placeholders.keys():
|
|
108
|
+
# Replace placeholder with a valid SQL parameter
|
|
109
|
+
temp_placeholder = f":{placeholder}"
|
|
110
|
+
temp_sql = temp_sql.replace(placeholder, temp_placeholder)
|
|
111
|
+
placeholder_mapping[temp_placeholder] = placeholder
|
|
112
|
+
|
|
113
|
+
# Handle automatic placeholders (%s, ?)
|
|
114
|
+
auto_placeholder_pattern = re.compile(r'%s|\?')
|
|
115
|
+
auto_placeholders = auto_placeholder_pattern.findall(temp_sql)
|
|
116
|
+
auto_placeholder_mapping = {}
|
|
117
|
+
for idx, ph in enumerate(auto_placeholders):
|
|
118
|
+
temp_placeholder = f":AUTO_PLACEHOLDER_{idx}"
|
|
119
|
+
temp_sql = temp_sql.replace(ph, temp_placeholder, 1)
|
|
120
|
+
auto_placeholder_mapping[temp_placeholder] = ph
|
|
121
|
+
|
|
122
|
+
temp_sql = temp_sql.strip()
|
|
123
|
+
|
|
124
|
+
# Parse and format SQL
|
|
125
|
+
parsed = parse_one(temp_sql, read=dialect)
|
|
126
|
+
formatted_sql = parsed.sql(
|
|
127
|
+
pretty=not force_single_line,
|
|
128
|
+
indent=config.indent_width,
|
|
129
|
+
dialect=dialect
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Apply formatting based on context
|
|
133
|
+
if is_magic_command and not is_cell_magic:
|
|
134
|
+
# Line magic: single line
|
|
135
|
+
formatted_sql = " ".join(formatted_sql.split())
|
|
136
|
+
elif force_single_line:
|
|
137
|
+
formatted_sql = " ".join(formatted_sql.split())
|
|
138
|
+
else:
|
|
139
|
+
formatted_sql = formatted_sql.strip()
|
|
140
|
+
|
|
141
|
+
# Restore placeholders in f-strings
|
|
142
|
+
if placeholders:
|
|
143
|
+
for temp_placeholder, original_placeholder in placeholder_mapping.items():
|
|
144
|
+
# Remove quotes around placeholders if any
|
|
145
|
+
formatted_sql = formatted_sql.replace(f"'{temp_placeholder}'", temp_placeholder)
|
|
146
|
+
formatted_sql = formatted_sql.replace(f'"{temp_placeholder}"', temp_placeholder)
|
|
147
|
+
# Replace temp placeholders with original placeholders
|
|
148
|
+
formatted_sql = formatted_sql.replace(temp_placeholder, original_placeholder)
|
|
149
|
+
|
|
150
|
+
# Restore automatic placeholders
|
|
151
|
+
for temp_placeholder, original_placeholder in auto_placeholder_mapping.items():
|
|
152
|
+
formatted_sql = formatted_sql.replace(temp_placeholder, original_placeholder)
|
|
153
|
+
|
|
154
|
+
# Logging for debugging purposes
|
|
155
|
+
logger.debug(f"Original SQL:\n{sql_code}")
|
|
156
|
+
logger.debug(f"Formatted SQL:\n{formatted_sql}")
|
|
157
|
+
|
|
158
|
+
return formatted_sql
|
|
159
|
+
|
|
160
|
+
except errors.ParseError as e:
|
|
161
|
+
raise SQLFormattingError(f"Failed to parse SQL code: {e}")
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise SQLFormattingError(f"Unexpected error during SQL formatting: {e}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SQLStringFormatter(ast.NodeTransformer):
|
|
167
|
+
"""AST NodeTransformer that formats SQL strings."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self, config: FormattingConfig, dialect: Optional[str], logger: logging.Logger
|
|
171
|
+
):
|
|
172
|
+
super().__init__()
|
|
173
|
+
self.config = config
|
|
174
|
+
self.dialect = dialect
|
|
175
|
+
self.logger = logger
|
|
176
|
+
self.changed = False
|
|
177
|
+
|
|
178
|
+
def is_likely_sql(self, code: str) -> bool:
|
|
179
|
+
"""Enhanced SQL detection with better heuristics."""
|
|
180
|
+
if not code or len(code.strip()) < 10:
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
if re.match(r"^\s*(/|[a-zA-Z]:\\|https?://|<!DOCTYPE|<html)", code.strip()):
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
upper_code = code.upper()
|
|
187
|
+
keyword_count = sum(
|
|
188
|
+
1
|
|
189
|
+
for keyword in self.config.sql_keywords
|
|
190
|
+
if re.search(rf"\b{re.escape(keyword)}\b", upper_code)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
has_sql_pattern = bool(
|
|
194
|
+
re.search(
|
|
195
|
+
r"\bSELECT\b.*\bFROM\b|\bUPDATE\b.*\bSET\b|\bINSERT\b.*\bINTO\b|\bDELETE\b.*\bFROM\b",
|
|
196
|
+
upper_code,
|
|
197
|
+
re.DOTALL,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return keyword_count >= 2 or has_sql_pattern
|
|
202
|
+
|
|
203
|
+
def extract_fstring_parts(self, node: ast.JoinedStr) -> Tuple[str, Dict[str, str]]:
|
|
204
|
+
"""Extracts parts of an f-string, preserving expressions."""
|
|
205
|
+
sql_parts = []
|
|
206
|
+
placeholders = {}
|
|
207
|
+
placeholder_counter = 0
|
|
208
|
+
|
|
209
|
+
for value in node.values:
|
|
210
|
+
if isinstance(value, ast.Constant):
|
|
211
|
+
sql_parts.append(value.value)
|
|
212
|
+
elif isinstance(value, ast.FormattedValue):
|
|
213
|
+
expr = astor.to_source(value.value).strip()
|
|
214
|
+
placeholder = f"PLACEHOLDER_{placeholder_counter}"
|
|
215
|
+
sql_parts.append(placeholder)
|
|
216
|
+
placeholders[placeholder] = expr
|
|
217
|
+
placeholder_counter += 1
|
|
218
|
+
|
|
219
|
+
return "".join(sql_parts), placeholders
|
|
220
|
+
|
|
221
|
+
def format_sql_node(
|
|
222
|
+
self, node: Union[ast.Constant, ast.JoinedStr], force_single_line: bool = False
|
|
223
|
+
) -> Optional[ast.AST]:
|
|
224
|
+
"""Formats SQL code in AST nodes."""
|
|
225
|
+
try:
|
|
226
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
227
|
+
if not self.is_likely_sql(node.value):
|
|
228
|
+
return None
|
|
229
|
+
formatted_sql = format_sql_code(
|
|
230
|
+
node.value,
|
|
231
|
+
self.dialect,
|
|
232
|
+
self.config,
|
|
233
|
+
force_single_line=force_single_line,
|
|
234
|
+
)
|
|
235
|
+
if formatted_sql != node.value:
|
|
236
|
+
self.changed = True
|
|
237
|
+
# Reconstruct the string with adjusted triple quotes
|
|
238
|
+
if '\n' in formatted_sql:
|
|
239
|
+
# Multi-line string: place triple quotes on their own lines
|
|
240
|
+
formatted_str = f'"""\n{formatted_sql}\n"""'
|
|
241
|
+
else:
|
|
242
|
+
# Single-line string
|
|
243
|
+
formatted_str = f'"""{formatted_sql}"""'
|
|
244
|
+
# Parse the formatted string back into an AST node
|
|
245
|
+
formatted_node = ast.parse(formatted_str).body[0].value
|
|
246
|
+
return formatted_node
|
|
247
|
+
|
|
248
|
+
elif isinstance(node, ast.JoinedStr):
|
|
249
|
+
sql_str, placeholders = self.extract_fstring_parts(node)
|
|
250
|
+
if not self.is_likely_sql(sql_str):
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
formatted_sql = format_sql_code(
|
|
254
|
+
sql_str,
|
|
255
|
+
self.dialect,
|
|
256
|
+
self.config,
|
|
257
|
+
placeholders=placeholders,
|
|
258
|
+
force_single_line=force_single_line,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if placeholders:
|
|
262
|
+
for placeholder in placeholders.keys():
|
|
263
|
+
# Remove quotes around placeholders in formatted SQL
|
|
264
|
+
formatted_sql = formatted_sql.replace(f"'{placeholder}'", placeholder)
|
|
265
|
+
formatted_sql = formatted_sql.replace(f'"{placeholder}"', placeholder)
|
|
266
|
+
|
|
267
|
+
if formatted_sql != sql_str:
|
|
268
|
+
self.changed = True
|
|
269
|
+
# Reconstruct the f-string
|
|
270
|
+
new_values = []
|
|
271
|
+
pattern = re.compile('|'.join(re.escape(k) for k in placeholders.keys()))
|
|
272
|
+
idx = 0
|
|
273
|
+
while idx < len(formatted_sql):
|
|
274
|
+
match = pattern.search(formatted_sql, idx)
|
|
275
|
+
if match:
|
|
276
|
+
if match.start() > idx:
|
|
277
|
+
# Add preceding text as Constant
|
|
278
|
+
new_values.append(ast.Constant(value=formatted_sql[idx:match.start()]))
|
|
279
|
+
placeholder = match.group()
|
|
280
|
+
expr_str = placeholders[placeholder].strip()
|
|
281
|
+
try:
|
|
282
|
+
expr_ast = ast.parse(expr_str, mode='eval').body
|
|
283
|
+
except SyntaxError as e:
|
|
284
|
+
self.logger.warning(f"Failed to parse expression '{expr_str}': {e}")
|
|
285
|
+
return None
|
|
286
|
+
new_values.append(ast.FormattedValue(
|
|
287
|
+
value=expr_ast,
|
|
288
|
+
conversion=-1,
|
|
289
|
+
format_spec=None
|
|
290
|
+
))
|
|
291
|
+
idx = match.end()
|
|
292
|
+
else:
|
|
293
|
+
# Add the rest of the string as Constant
|
|
294
|
+
new_values.append(ast.Constant(value=formatted_sql[idx:]))
|
|
295
|
+
break
|
|
296
|
+
new_node = ast.JoinedStr(values=new_values)
|
|
297
|
+
# Wrap the f-string with adjusted triple quotes
|
|
298
|
+
if '\n' in formatted_sql:
|
|
299
|
+
# Multi-line f-string
|
|
300
|
+
formatted_fstring = ast.JoinedStr(values=[
|
|
301
|
+
ast.Constant(value='\n'),
|
|
302
|
+
*new_node.values,
|
|
303
|
+
ast.Constant(value='\n')
|
|
304
|
+
])
|
|
305
|
+
else:
|
|
306
|
+
# Single-line f-string
|
|
307
|
+
formatted_fstring = new_node
|
|
308
|
+
return formatted_fstring
|
|
309
|
+
return None
|
|
310
|
+
except SQLFormattingError as e:
|
|
311
|
+
self.logger.warning(f"SQL formatting skipped: {e}")
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def visit_Assign(self, node: ast.Assign) -> ast.AST:
|
|
315
|
+
"""Handles assignments."""
|
|
316
|
+
if isinstance(node.value, (ast.Constant, ast.JoinedStr)):
|
|
317
|
+
formatted_node = self.format_sql_node(node.value)
|
|
318
|
+
if formatted_node:
|
|
319
|
+
node.value = formatted_node
|
|
320
|
+
return self.generic_visit(node)
|
|
321
|
+
|
|
322
|
+
def visit_Call(self, node: ast.Call) -> ast.AST:
|
|
323
|
+
"""Handles function calls."""
|
|
324
|
+
func_name = self.get_full_func_name(node.func)
|
|
325
|
+
if any(name in func_name for name in self.config.function_names):
|
|
326
|
+
for idx, arg in enumerate(node.args):
|
|
327
|
+
if isinstance(arg, (ast.Constant, ast.JoinedStr)):
|
|
328
|
+
formatted_node = self.format_sql_node(arg)
|
|
329
|
+
if formatted_node:
|
|
330
|
+
node.args[idx] = formatted_node
|
|
331
|
+
for keyword in node.keywords:
|
|
332
|
+
if isinstance(keyword.value, (ast.Constant, ast.JoinedStr)):
|
|
333
|
+
formatted_node = self.format_sql_node(keyword.value)
|
|
334
|
+
if formatted_node:
|
|
335
|
+
keyword.value = formatted_node
|
|
336
|
+
return self.generic_visit(node)
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def get_full_func_name(node: ast.AST) -> str:
|
|
340
|
+
"""Gets the full function name from an AST node."""
|
|
341
|
+
parts = []
|
|
342
|
+
while isinstance(node, ast.Attribute):
|
|
343
|
+
parts.append(node.attr)
|
|
344
|
+
node = node.value
|
|
345
|
+
if isinstance(node, ast.Name):
|
|
346
|
+
parts.append(node.id)
|
|
347
|
+
return ".".join(reversed(parts))
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def process_notebook(
|
|
351
|
+
notebook_path: Union[str, Path],
|
|
352
|
+
config: FormattingConfig,
|
|
353
|
+
dialect: Optional[str],
|
|
354
|
+
logger: logging.Logger,
|
|
355
|
+
) -> bool:
|
|
356
|
+
"""Processes a Jupyter notebook."""
|
|
357
|
+
try:
|
|
358
|
+
notebook = nbformat.read(notebook_path, as_version=4)
|
|
359
|
+
changed = False
|
|
360
|
+
|
|
361
|
+
for cell in notebook.cells:
|
|
362
|
+
if cell.cell_type != "code":
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
original_code = cell.source
|
|
366
|
+
if not original_code.strip():
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
lines = original_code.split("\n")
|
|
370
|
+
|
|
371
|
+
# Initialize variables to track magic commands
|
|
372
|
+
magic_cmd = None
|
|
373
|
+
magic_cmd_index = None
|
|
374
|
+
|
|
375
|
+
# Iterate through lines to find the first non-comment magic command
|
|
376
|
+
for idx, line in enumerate(lines):
|
|
377
|
+
stripped = line.strip()
|
|
378
|
+
|
|
379
|
+
if not stripped:
|
|
380
|
+
continue # Skip empty lines
|
|
381
|
+
|
|
382
|
+
if stripped.startswith("#"):
|
|
383
|
+
continue # Skip comment lines
|
|
384
|
+
|
|
385
|
+
if stripped.startswith("%%sql") or stripped.startswith("%sql"):
|
|
386
|
+
magic_cmd = stripped.split()[0]
|
|
387
|
+
magic_cmd_index = idx
|
|
388
|
+
break # Magic command found
|
|
389
|
+
|
|
390
|
+
else:
|
|
391
|
+
break # Non-magic, non-comment line found
|
|
392
|
+
|
|
393
|
+
if magic_cmd:
|
|
394
|
+
is_cell_magic = magic_cmd.startswith("%%sql")
|
|
395
|
+
|
|
396
|
+
if is_cell_magic:
|
|
397
|
+
# Cell magic: SQL code starts from the next line
|
|
398
|
+
sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
|
|
399
|
+
else:
|
|
400
|
+
# Line magic: SQL code is on the same line after the magic command
|
|
401
|
+
sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
formatted_sql = format_sql_code(
|
|
405
|
+
sql_code,
|
|
406
|
+
dialect,
|
|
407
|
+
config,
|
|
408
|
+
is_magic_command=True,
|
|
409
|
+
is_cell_magic=is_cell_magic,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Reconstruct the cell content
|
|
413
|
+
if is_cell_magic:
|
|
414
|
+
# Preserve comments before the magic command
|
|
415
|
+
pre_magic = "\n".join(lines[:magic_cmd_index])
|
|
416
|
+
if pre_magic:
|
|
417
|
+
new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
|
|
418
|
+
else:
|
|
419
|
+
new_content = f"{magic_cmd}\n{formatted_sql}"
|
|
420
|
+
else:
|
|
421
|
+
# Preserve comments before the magic command
|
|
422
|
+
pre_magic = "\n".join(lines[:magic_cmd_index])
|
|
423
|
+
if pre_magic:
|
|
424
|
+
new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
|
|
425
|
+
else:
|
|
426
|
+
new_content = f"{magic_cmd} {formatted_sql}"
|
|
427
|
+
|
|
428
|
+
if new_content != original_code:
|
|
429
|
+
cell.source = new_content
|
|
430
|
+
changed = True
|
|
431
|
+
|
|
432
|
+
except SQLFormattingError as e:
|
|
433
|
+
logger.warning(f"SQL magic formatting skipped: {e}")
|
|
434
|
+
|
|
435
|
+
continue # Move to the next cell after handling magic command
|
|
436
|
+
|
|
437
|
+
# Handle regular Python code
|
|
438
|
+
try:
|
|
439
|
+
tree = ast.parse(original_code)
|
|
440
|
+
formatter = SQLStringFormatter(config, dialect, logger)
|
|
441
|
+
new_tree = formatter.visit(tree)
|
|
442
|
+
if formatter.changed:
|
|
443
|
+
# Use astor to convert AST back to source code
|
|
444
|
+
formatted_code = astor.to_source(new_tree)
|
|
445
|
+
if formatted_code != original_code:
|
|
446
|
+
cell.source = formatted_code
|
|
447
|
+
changed = True
|
|
448
|
+
except SyntaxError:
|
|
449
|
+
logger.warning(f"Failed to parse cell:\n{original_code}")
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
if changed:
|
|
453
|
+
nbformat.write(notebook, notebook_path)
|
|
454
|
+
logger.info(f"Updated notebook: {notebook_path}")
|
|
455
|
+
|
|
456
|
+
return changed
|
|
457
|
+
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.error(f"Failed to process notebook {notebook_path}: {e}")
|
|
460
|
+
raise
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def main():
|
|
464
|
+
"""Main entry point for the SQL formatter."""
|
|
465
|
+
import argparse
|
|
466
|
+
|
|
467
|
+
parser = argparse.ArgumentParser(description="Format SQL code in Jupyter notebooks")
|
|
468
|
+
parser.add_argument(
|
|
469
|
+
"notebooks", nargs="+", type=Path, help="Notebook paths to process"
|
|
470
|
+
)
|
|
471
|
+
parser.add_argument(
|
|
472
|
+
"--config", type=Path, default="config.yaml", help="Configuration file path"
|
|
473
|
+
)
|
|
474
|
+
parser.add_argument(
|
|
475
|
+
"--dialect", type=str, help="SQL dialect (e.g., mysql, postgres)"
|
|
476
|
+
)
|
|
477
|
+
parser.add_argument(
|
|
478
|
+
"--log-level",
|
|
479
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
480
|
+
default="INFO",
|
|
481
|
+
help="Logging level",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
args = parser.parse_args()
|
|
485
|
+
logger = setup_logging(args.log_level)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
config = load_config(args.config)
|
|
489
|
+
changed = False
|
|
490
|
+
|
|
491
|
+
for notebook in args.notebooks:
|
|
492
|
+
if process_notebook(notebook, config, args.dialect, logger):
|
|
493
|
+
changed = True
|
|
494
|
+
|
|
495
|
+
sys.exit(0 if changed else 1)
|
|
496
|
+
|
|
497
|
+
except Exception as e:
|
|
498
|
+
logger.error(f"Fatal error: {e}")
|
|
499
|
+
sys.exit(2)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
if __name__ == "__main__":
|
|
503
|
+
main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import nbformat
|
|
3
|
+
from nbformat import v4 as nbf
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# Add the parent directory to sys.path to import the formatter module
|
|
9
|
+
sys.path.append(str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
|
|
11
|
+
from sqlnbfmt.formatter import process_notebook, FormattingConfig, load_config
|
|
12
|
+
|
|
13
|
+
# Fixture for temporary notebook path
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def temp_nb_path(tmp_path):
|
|
16
|
+
return tmp_path / "temp_test_notebook.ipynb"
|
|
17
|
+
|
|
18
|
+
# Fixture for logger
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def logger():
|
|
21
|
+
# Set up a logger for testing
|
|
22
|
+
logger = logging.getLogger('test_formatter')
|
|
23
|
+
logger.setLevel(logging.DEBUG)
|
|
24
|
+
if not logger.handlers:
|
|
25
|
+
handler = logging.StreamHandler()
|
|
26
|
+
formatter = logging.Formatter("[%(levelname)s] %(message)s")
|
|
27
|
+
handler.setFormatter(formatter)
|
|
28
|
+
logger.addHandler(handler)
|
|
29
|
+
return logger
|
|
30
|
+
|
|
31
|
+
# Test function with updated expected outputs
|
|
32
|
+
@pytest.mark.parametrize("input_cells, expected_cells", [
|
|
33
|
+
# Test Case 1: Simple SELECT Query
|
|
34
|
+
(
|
|
35
|
+
[nbf.new_code_cell("""query = "select id, name from users where active = 1" """)],
|
|
36
|
+
[nbf.new_code_cell('''query = """
|
|
37
|
+
SELECT
|
|
38
|
+
id,
|
|
39
|
+
name
|
|
40
|
+
FROM users
|
|
41
|
+
WHERE
|
|
42
|
+
active = 1
|
|
43
|
+
"""''')],
|
|
44
|
+
),
|
|
45
|
+
# Test Case 2: SELECT Query with F-String and Variable
|
|
46
|
+
(
|
|
47
|
+
[nbf.new_code_cell('''table_name = "users"\nquery = f"select * from {table_name}"''')],
|
|
48
|
+
[nbf.new_code_cell('''table_name = 'users'\nquery = f"""
|
|
49
|
+
SELECT
|
|
50
|
+
*
|
|
51
|
+
FROM {table_name}
|
|
52
|
+
"""''')],
|
|
53
|
+
),
|
|
54
|
+
# Test Case 3: Multi-line SELECT Query with Variables
|
|
55
|
+
(
|
|
56
|
+
[nbf.new_code_cell('''columns = "id, name"\nquery = f"select {columns} from users where active = 1"''')],
|
|
57
|
+
[nbf.new_code_cell('''columns = 'id, name'\nquery = f"""
|
|
58
|
+
SELECT
|
|
59
|
+
{columns}
|
|
60
|
+
FROM users
|
|
61
|
+
WHERE
|
|
62
|
+
active = 1
|
|
63
|
+
"""''')],
|
|
64
|
+
),
|
|
65
|
+
# Test Case 4: SQL Query with Line Magic Command
|
|
66
|
+
(
|
|
67
|
+
[nbf.new_code_cell("""%sql select * from users where active = 1""")],
|
|
68
|
+
[nbf.new_code_cell("""%sql SELECT * FROM users WHERE active = 1""")],
|
|
69
|
+
),
|
|
70
|
+
# Test Case 5: SQL Query with Cell Magic Command
|
|
71
|
+
(
|
|
72
|
+
[nbf.new_code_cell('''%%sql\nselect * from users where active = 1''')],
|
|
73
|
+
[nbf.new_code_cell('''%%sql\nSELECT\n *\nFROM users\nWHERE\n active = 1''')],
|
|
74
|
+
),
|
|
75
|
+
# Test Case 6: INSERT Query with Variables (Updated to handle formatter limitations)
|
|
76
|
+
(
|
|
77
|
+
[nbf.new_code_cell('''table_name = "users"\ncolumns = "(id, name)"\nvalues = "(1, 'Alice')"\nquery = f"insert into {table_name} {columns} values {values}"''')],
|
|
78
|
+
# Since the formatter cannot handle placeholders here, we expect the original code to remain unchanged
|
|
79
|
+
[nbf.new_code_cell('''table_name = "users"\ncolumns = "(id, name)"\nvalues = "(1, 'Alice')"\nquery = f"insert into {table_name} {columns} values {values}"''')],
|
|
80
|
+
),
|
|
81
|
+
# Test Case 7: UPDATE Query with Variables
|
|
82
|
+
(
|
|
83
|
+
[nbf.new_code_cell('''table_name = "users"\nquery = f"update {table_name} set active = 0 where id = 1"''')],
|
|
84
|
+
[nbf.new_code_cell('''table_name = 'users'\nquery = f"""
|
|
85
|
+
UPDATE {table_name} SET active = 0
|
|
86
|
+
WHERE
|
|
87
|
+
id = 1
|
|
88
|
+
"""''')],
|
|
89
|
+
),
|
|
90
|
+
# Test Case 8: DELETE Query
|
|
91
|
+
(
|
|
92
|
+
[nbf.new_code_cell("""query = "delete from users where active = 0" """)],
|
|
93
|
+
[nbf.new_code_cell('''query = """
|
|
94
|
+
DELETE FROM users
|
|
95
|
+
WHERE
|
|
96
|
+
active = 0
|
|
97
|
+
"""''')],
|
|
98
|
+
),
|
|
99
|
+
# Test Case 9: Query with Comments
|
|
100
|
+
(
|
|
101
|
+
[nbf.new_code_cell('''query = "-- Select active users\\nselect id, name from users where active = 1"''')],
|
|
102
|
+
[nbf.new_code_cell('''query = """
|
|
103
|
+
/* Select active users */
|
|
104
|
+
SELECT
|
|
105
|
+
id,
|
|
106
|
+
name
|
|
107
|
+
FROM users
|
|
108
|
+
WHERE
|
|
109
|
+
active = 1
|
|
110
|
+
"""''')],
|
|
111
|
+
),
|
|
112
|
+
# Test Case 10: Non-SQL String (Should Remain Unchanged)
|
|
113
|
+
(
|
|
114
|
+
[nbf.new_code_cell('''message = "This is not an SQL query: select * from users;"''')],
|
|
115
|
+
[nbf.new_code_cell('''message = "This is not an SQL query: select * from users;"''')],
|
|
116
|
+
),
|
|
117
|
+
])
|
|
118
|
+
def test_sql_formatter(temp_nb_path, logger, input_cells, expected_cells):
|
|
119
|
+
# Create a new notebook
|
|
120
|
+
nb = nbf.new_notebook()
|
|
121
|
+
nb.cells = input_cells
|
|
122
|
+
|
|
123
|
+
# Write the notebook to the temporary path
|
|
124
|
+
nbformat.write(nb, temp_nb_path)
|
|
125
|
+
|
|
126
|
+
# Load configuration
|
|
127
|
+
config = load_config('config.yaml')
|
|
128
|
+
dialect = 'mysql' # or your preferred SQL dialect
|
|
129
|
+
|
|
130
|
+
# Run the formatter on the notebook
|
|
131
|
+
process_notebook(temp_nb_path, config, dialect, logger=logger)
|
|
132
|
+
|
|
133
|
+
# Read the formatted notebook
|
|
134
|
+
formatted_nb = nbformat.read(temp_nb_path, as_version=4)
|
|
135
|
+
|
|
136
|
+
# Compare the cells
|
|
137
|
+
for formatted_cell, expected_cell in zip(formatted_nb.cells, expected_cells):
|
|
138
|
+
formatted_code = formatted_cell.source.strip()
|
|
139
|
+
expected_code = expected_cell.source.strip()
|
|
140
|
+
assert formatted_code == expected_code, f"Formatted code does not match expected output.\nExpected:\n{expected_code}\nActual:\n{formatted_code}"
|
|
141
|
+
|
|
142
|
+
# Clean up
|
|
143
|
+
temp_nb_path.unlink()
|