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.
@@ -1,9 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: sqlnbfmt
3
- Version: 0.1.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.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.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