sqlnbfmt 0.1.1__tar.gz → 0.1.3__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.3}/PKG-INFO +3 -1
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/pyproject.toml +5 -1
- sqlnbfmt-0.1.3/sqlnbfmt/formatter.py +528 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3/sqlnbfmt.egg-info}/PKG-INFO +3 -1
- sqlnbfmt-0.1.3/tests/test_formatter.py +165 -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.3}/LICENSE +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/README.md +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/setup.cfg +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/sqlnbfmt/__init__.py +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/sqlnbfmt.egg-info/SOURCES.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/sqlnbfmt.egg-info/dependency_links.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/sqlnbfmt.egg-info/entry_points.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/sqlnbfmt.egg-info/requires.txt +0 -0
- {sqlnbfmt-0.1.1 → sqlnbfmt-0.1.3}/sqlnbfmt.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: sqlnbfmt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A tool to format SQL code in Jupyter notebooks.
|
|
5
5
|
Author-email: Qing <qingye779@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
9
|
Requires-Python: >=3.12
|
|
8
10
|
Description-Content-Type: text/markdown
|
|
9
11
|
License-File: LICENSE
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlnbfmt"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "A tool to format SQL code in Jupyter notebooks."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Qing", email = "qingye779@gmail.com" }
|
|
@@ -8,6 +8,10 @@ authors = [
|
|
|
8
8
|
license = { text = "MIT" }
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Programming Language :: Python :: 3",
|
|
13
|
+
"Programming Language :: Python :: 3.12"
|
|
14
|
+
]
|
|
11
15
|
dependencies = [
|
|
12
16
|
"astor>=0.8.1",
|
|
13
17
|
"nbformat>=5.10.4",
|
|
@@ -0,0 +1,528 @@
|
|
|
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
|
+
# Handle empty f-strings or f-strings with only constants
|
|
210
|
+
if all(isinstance(value, ast.Constant) for value in node.values):
|
|
211
|
+
return "".join(value.value for value in node.values), {}
|
|
212
|
+
|
|
213
|
+
for value in node.values:
|
|
214
|
+
if isinstance(value, ast.Constant):
|
|
215
|
+
sql_parts.append(value.value)
|
|
216
|
+
elif isinstance(value, ast.FormattedValue):
|
|
217
|
+
expr = astor.to_source(value.value).strip()
|
|
218
|
+
placeholder = f"PLACEHOLDER_{placeholder_counter}"
|
|
219
|
+
sql_parts.append(placeholder)
|
|
220
|
+
placeholders[placeholder] = expr
|
|
221
|
+
placeholder_counter += 1
|
|
222
|
+
|
|
223
|
+
return "".join(sql_parts), placeholders
|
|
224
|
+
|
|
225
|
+
def format_sql_node(
|
|
226
|
+
self, node: Union[ast.Constant, ast.JoinedStr], force_single_line: bool = False
|
|
227
|
+
) -> Optional[ast.AST]:
|
|
228
|
+
"""Formats SQL code in AST nodes."""
|
|
229
|
+
try:
|
|
230
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
231
|
+
if not self.is_likely_sql(node.value):
|
|
232
|
+
return None
|
|
233
|
+
formatted_sql = format_sql_code(
|
|
234
|
+
node.value,
|
|
235
|
+
self.dialect,
|
|
236
|
+
self.config,
|
|
237
|
+
force_single_line=force_single_line,
|
|
238
|
+
)
|
|
239
|
+
if formatted_sql != node.value:
|
|
240
|
+
self.changed = True
|
|
241
|
+
# Reconstruct the string with adjusted triple quotes
|
|
242
|
+
if '\n' in formatted_sql:
|
|
243
|
+
formatted_str = f'"""\n{formatted_sql}\n"""'
|
|
244
|
+
else:
|
|
245
|
+
formatted_str = f'"""{formatted_sql}"""'
|
|
246
|
+
# Parse the formatted string back into an AST node
|
|
247
|
+
formatted_node = ast.parse(formatted_str).body[0].value
|
|
248
|
+
return formatted_node
|
|
249
|
+
|
|
250
|
+
elif isinstance(node, ast.JoinedStr):
|
|
251
|
+
sql_str, placeholders = self.extract_fstring_parts(node)
|
|
252
|
+
if not sql_str or not self.is_likely_sql(sql_str):
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
formatted_sql = format_sql_code(
|
|
257
|
+
sql_str,
|
|
258
|
+
self.dialect,
|
|
259
|
+
self.config,
|
|
260
|
+
placeholders=placeholders,
|
|
261
|
+
force_single_line=force_single_line,
|
|
262
|
+
)
|
|
263
|
+
except SQLFormattingError:
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
if placeholders:
|
|
267
|
+
for placeholder in placeholders.keys():
|
|
268
|
+
if not placeholder: # Skip empty placeholders
|
|
269
|
+
continue
|
|
270
|
+
# Remove quotes around placeholders in formatted SQL
|
|
271
|
+
formatted_sql = formatted_sql.replace(f"'{placeholder}'", placeholder)
|
|
272
|
+
formatted_sql = formatted_sql.replace(f'"{placeholder}"', placeholder)
|
|
273
|
+
|
|
274
|
+
if formatted_sql != sql_str:
|
|
275
|
+
self.changed = True
|
|
276
|
+
# Reconstruct the f-string
|
|
277
|
+
new_values = []
|
|
278
|
+
if not placeholders:
|
|
279
|
+
# If no placeholders, just create a simple f-string
|
|
280
|
+
new_values.append(ast.Constant(value=formatted_sql))
|
|
281
|
+
else:
|
|
282
|
+
pattern = re.compile('|'.join(re.escape(k) for k in placeholders.keys() if k))
|
|
283
|
+
idx = 0
|
|
284
|
+
while idx < len(formatted_sql):
|
|
285
|
+
match = pattern.search(formatted_sql, idx)
|
|
286
|
+
if match:
|
|
287
|
+
if match.start() > idx:
|
|
288
|
+
# Add preceding text as Constant
|
|
289
|
+
new_values.append(ast.Constant(value=formatted_sql[idx:match.start()]))
|
|
290
|
+
placeholder = match.group()
|
|
291
|
+
expr_str = placeholders[placeholder].strip()
|
|
292
|
+
try:
|
|
293
|
+
expr_ast = ast.parse(expr_str, mode='eval').body
|
|
294
|
+
except SyntaxError as e:
|
|
295
|
+
self.logger.warning(f"Failed to parse expression '{expr_str}': {e}")
|
|
296
|
+
return None
|
|
297
|
+
new_values.append(ast.FormattedValue(
|
|
298
|
+
value=expr_ast,
|
|
299
|
+
conversion=-1,
|
|
300
|
+
format_spec=None
|
|
301
|
+
))
|
|
302
|
+
idx = match.end()
|
|
303
|
+
else:
|
|
304
|
+
# Add the rest of the string as Constant
|
|
305
|
+
new_values.append(ast.Constant(value=formatted_sql[idx:]))
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
new_node = ast.JoinedStr(values=new_values)
|
|
309
|
+
# Wrap the f-string with adjusted triple quotes
|
|
310
|
+
if '\n' in formatted_sql:
|
|
311
|
+
# Multi-line f-string
|
|
312
|
+
formatted_fstring = ast.JoinedStr(values=[
|
|
313
|
+
ast.Constant(value='\n'),
|
|
314
|
+
*new_node.values,
|
|
315
|
+
ast.Constant(value='\n')
|
|
316
|
+
])
|
|
317
|
+
else:
|
|
318
|
+
# Single-line f-string
|
|
319
|
+
formatted_fstring = new_node
|
|
320
|
+
return formatted_fstring
|
|
321
|
+
return None
|
|
322
|
+
except SQLFormattingError as e:
|
|
323
|
+
self.logger.warning(f"SQL formatting skipped: {e}")
|
|
324
|
+
return None
|
|
325
|
+
except Exception as e:
|
|
326
|
+
self.logger.warning(f"Unexpected error during SQL formatting: {e}")
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
def visit_Assign(self, node: ast.Assign) -> ast.AST:
|
|
330
|
+
"""Handles assignments."""
|
|
331
|
+
if isinstance(node.value, (ast.Constant, ast.JoinedStr)):
|
|
332
|
+
formatted_node = self.format_sql_node(node.value)
|
|
333
|
+
if formatted_node:
|
|
334
|
+
node.value = formatted_node
|
|
335
|
+
return self.generic_visit(node)
|
|
336
|
+
|
|
337
|
+
def visit_Call(self, node: ast.Call) -> ast.AST:
|
|
338
|
+
"""Handles function calls."""
|
|
339
|
+
func_name = self.get_full_func_name(node.func)
|
|
340
|
+
if any(name in func_name for name in self.config.function_names):
|
|
341
|
+
for idx, arg in enumerate(node.args):
|
|
342
|
+
if isinstance(arg, (ast.Constant, ast.JoinedStr)):
|
|
343
|
+
formatted_node = self.format_sql_node(arg)
|
|
344
|
+
if formatted_node:
|
|
345
|
+
node.args[idx] = formatted_node
|
|
346
|
+
for keyword in node.keywords:
|
|
347
|
+
if isinstance(keyword.value, (ast.Constant, ast.JoinedStr)):
|
|
348
|
+
formatted_node = self.format_sql_node(keyword.value)
|
|
349
|
+
if formatted_node:
|
|
350
|
+
keyword.value = formatted_node
|
|
351
|
+
return self.generic_visit(node)
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def get_full_func_name(node: ast.AST) -> str:
|
|
355
|
+
"""Gets the full function name from an AST node."""
|
|
356
|
+
parts = []
|
|
357
|
+
while isinstance(node, ast.Attribute):
|
|
358
|
+
parts.append(node.attr)
|
|
359
|
+
node = node.value
|
|
360
|
+
if isinstance(node, ast.Name):
|
|
361
|
+
parts.append(node.id)
|
|
362
|
+
return ".".join(reversed(parts))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def process_notebook(
|
|
366
|
+
notebook_path: Union[str, Path],
|
|
367
|
+
config: FormattingConfig,
|
|
368
|
+
dialect: Optional[str],
|
|
369
|
+
logger: logging.Logger,
|
|
370
|
+
) -> bool:
|
|
371
|
+
"""Processes a Jupyter notebook."""
|
|
372
|
+
try:
|
|
373
|
+
notebook = nbformat.read(notebook_path, as_version=4)
|
|
374
|
+
changed = False
|
|
375
|
+
|
|
376
|
+
for cell in notebook.cells:
|
|
377
|
+
if cell.cell_type != "code":
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
original_code = cell.source
|
|
381
|
+
if not original_code.strip():
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
lines = original_code.split("\n")
|
|
385
|
+
|
|
386
|
+
# Initialize variables to track magic commands
|
|
387
|
+
magic_cmd = None
|
|
388
|
+
magic_cmd_index = None
|
|
389
|
+
|
|
390
|
+
# Iterate through lines to find the first non-comment magic command
|
|
391
|
+
for idx, line in enumerate(lines):
|
|
392
|
+
stripped = line.strip()
|
|
393
|
+
|
|
394
|
+
if not stripped:
|
|
395
|
+
continue # Skip empty lines
|
|
396
|
+
|
|
397
|
+
if stripped.startswith("#"):
|
|
398
|
+
continue # Skip comment lines
|
|
399
|
+
|
|
400
|
+
if stripped.startswith("%%sql") or stripped.startswith("%sql"):
|
|
401
|
+
magic_cmd = stripped.split()[0]
|
|
402
|
+
magic_cmd_index = idx
|
|
403
|
+
break # Magic command found
|
|
404
|
+
|
|
405
|
+
else:
|
|
406
|
+
break # Non-magic, non-comment line found
|
|
407
|
+
|
|
408
|
+
if magic_cmd:
|
|
409
|
+
is_cell_magic = magic_cmd.startswith("%%sql")
|
|
410
|
+
|
|
411
|
+
if is_cell_magic:
|
|
412
|
+
# Cell magic: SQL code starts from the next line
|
|
413
|
+
sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
|
|
414
|
+
else:
|
|
415
|
+
# Line magic: SQL code is on the same line after the magic command
|
|
416
|
+
sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
formatted_sql = format_sql_code(
|
|
420
|
+
sql_code,
|
|
421
|
+
dialect,
|
|
422
|
+
config,
|
|
423
|
+
is_magic_command=True,
|
|
424
|
+
is_cell_magic=is_cell_magic,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Reconstruct the cell content
|
|
428
|
+
if is_cell_magic:
|
|
429
|
+
# Preserve comments before the magic command
|
|
430
|
+
pre_magic = "\n".join(lines[:magic_cmd_index])
|
|
431
|
+
if pre_magic:
|
|
432
|
+
new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
|
|
433
|
+
else:
|
|
434
|
+
new_content = f"{magic_cmd}\n{formatted_sql}"
|
|
435
|
+
else:
|
|
436
|
+
# Preserve comments before the magic command
|
|
437
|
+
pre_magic = "\n".join(lines[:magic_cmd_index])
|
|
438
|
+
if pre_magic:
|
|
439
|
+
new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
|
|
440
|
+
else:
|
|
441
|
+
new_content = f"{magic_cmd} {formatted_sql}"
|
|
442
|
+
|
|
443
|
+
if new_content != original_code:
|
|
444
|
+
cell.source = new_content
|
|
445
|
+
changed = True
|
|
446
|
+
|
|
447
|
+
except SQLFormattingError as e:
|
|
448
|
+
logger.warning(f"SQL magic formatting skipped: {e}")
|
|
449
|
+
|
|
450
|
+
continue # Move to the next cell after handling magic command
|
|
451
|
+
|
|
452
|
+
# Handle regular Python code
|
|
453
|
+
try:
|
|
454
|
+
tree = ast.parse(original_code)
|
|
455
|
+
formatter = SQLStringFormatter(config, dialect, logger)
|
|
456
|
+
new_tree = formatter.visit(tree)
|
|
457
|
+
if formatter.changed:
|
|
458
|
+
# Use astor to convert AST back to source code
|
|
459
|
+
formatted_code = astor.to_source(new_tree)
|
|
460
|
+
if formatted_code != original_code:
|
|
461
|
+
cell.source = formatted_code
|
|
462
|
+
changed = True
|
|
463
|
+
except SyntaxError:
|
|
464
|
+
logger.warning(f"Failed to parse cell:\n{original_code}")
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
if changed:
|
|
468
|
+
nbformat.write(notebook, notebook_path)
|
|
469
|
+
logger.info(f"Updated notebook: {notebook_path}")
|
|
470
|
+
|
|
471
|
+
return changed
|
|
472
|
+
|
|
473
|
+
except Exception as e:
|
|
474
|
+
logger.error(f"Failed to process notebook {notebook_path}: {e}", exc_info=True)
|
|
475
|
+
raise
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def main():
|
|
479
|
+
"""Main entry point for the SQL formatter."""
|
|
480
|
+
import argparse
|
|
481
|
+
|
|
482
|
+
parser = argparse.ArgumentParser(description="Format SQL code in Jupyter notebooks")
|
|
483
|
+
parser.add_argument(
|
|
484
|
+
"notebooks", nargs="+", type=Path, help="Notebook paths to process"
|
|
485
|
+
)
|
|
486
|
+
parser.add_argument(
|
|
487
|
+
"--config", type=Path, default="config.yaml", help="Configuration file path"
|
|
488
|
+
)
|
|
489
|
+
parser.add_argument(
|
|
490
|
+
"--dialect", type=str, help="SQL dialect (e.g., mysql, postgres)"
|
|
491
|
+
)
|
|
492
|
+
parser.add_argument(
|
|
493
|
+
"--log-level",
|
|
494
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
495
|
+
default="INFO",
|
|
496
|
+
help="Logging level",
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
args = parser.parse_args()
|
|
500
|
+
logger = setup_logging(args.log_level)
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
config = load_config(args.config)
|
|
504
|
+
changed = False
|
|
505
|
+
changed_files = []
|
|
506
|
+
|
|
507
|
+
for notebook in args.notebooks:
|
|
508
|
+
if process_notebook(notebook, config, args.dialect, logger):
|
|
509
|
+
changed = True
|
|
510
|
+
changed_files.append(notebook)
|
|
511
|
+
|
|
512
|
+
# Print summary
|
|
513
|
+
if changed:
|
|
514
|
+
logger.info("Changes made to the following notebooks:")
|
|
515
|
+
for file in changed_files:
|
|
516
|
+
logger.info(f" - {file}")
|
|
517
|
+
else:
|
|
518
|
+
logger.info("No changes needed. All notebooks are properly formatted.")
|
|
519
|
+
|
|
520
|
+
sys.exit(0)
|
|
521
|
+
|
|
522
|
+
except Exception as e:
|
|
523
|
+
logger.error(f"Fatal error: {e}")
|
|
524
|
+
sys.exit(1)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
if __name__ == "__main__":
|
|
528
|
+
main()
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: sqlnbfmt
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: A tool to format SQL code in Jupyter notebooks.
|
|
5
5
|
Author-email: Qing <qingye779@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
9
|
Requires-Python: >=3.12
|
|
8
10
|
Description-Content-Type: text/markdown
|
|
9
11
|
License-File: LICENSE
|