sqlnbfmt 0.3.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A tool to format SQL code in Jupyter notebooks.
5
5
  Author-email: Qing <qingye779@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlnbfmt"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "A tool to format SQL code in Jupyter notebooks."
5
5
  authors = [
6
6
  { name = "Qing", email = "qingye779@gmail.com" }
@@ -10,7 +10,7 @@ from typing import Set, Optional, Dict, Union, Tuple
10
10
 
11
11
  import nbformat
12
12
  import yaml
13
- from sqlglot import parse_one, errors
13
+ from sqlglot import parse, errors
14
14
 
15
15
  DEFAULT_SQL_KEYWORDS = frozenset(
16
16
  {
@@ -116,10 +116,16 @@ def load_config(config_path: Optional[Union[str, Path]] = None) -> FormattingCon
116
116
  try:
117
117
  with open(config_file) as file:
118
118
  config = yaml.safe_load(file)
119
+ if config is None:
120
+ return FormattingConfig()
119
121
  return FormattingConfig(
120
- sql_keywords=set(config.get("sql_keywords", [])),
121
- function_names=set(config.get("function_names", [])),
122
- sql_decorators=set(config.get("sql_decorators", [])),
122
+ sql_keywords=set(config.get("sql_keywords", DEFAULT_SQL_KEYWORDS)),
123
+ function_names=set(
124
+ config.get("function_names", DEFAULT_FUNCTION_NAMES)
125
+ ),
126
+ sql_decorators=set(
127
+ config.get("sql_decorators", DEFAULT_SQL_DECORATORS)
128
+ ),
123
129
  single_line_threshold=config.get("formatting_options", {}).get(
124
130
  "single_line_threshold", 80
125
131
  ),
@@ -200,10 +206,18 @@ def format_sql_code(
200
206
  temp_sql = temp_sql.strip()
201
207
 
202
208
  # Parse and format SQL
203
- parsed = parse_one(temp_sql, read=dialect)
204
- formatted_sql = parsed.sql(
205
- pretty=not force_single_line, indent=config.indent_width, dialect=dialect
206
- )
209
+ statements = parse(temp_sql, read=dialect)
210
+ formatted_parts = []
211
+ for stmt in statements:
212
+ formatted_parts.append(
213
+ stmt.sql(
214
+ pretty=not force_single_line,
215
+ indent=config.indent_width,
216
+ dialect=dialect,
217
+ )
218
+ )
219
+ separator = "; " if force_single_line else ";\n"
220
+ formatted_sql = separator.join(formatted_parts)
207
221
 
208
222
  # Apply formatting based on context
209
223
  if is_magic_command and not is_cell_magic:
@@ -372,7 +386,7 @@ class SQLStringFormatter(ast.NodeVisitor):
372
386
  """Constructs the replacement string literal text."""
373
387
  prefix = "f" if is_fstring else ""
374
388
  if in_function and "\n" in formatted_sql:
375
- indent = " " * 4
389
+ indent = " " * self.config.indent_width
376
390
  lines = formatted_sql.split("\n")
377
391
  indented_lines = [indent + line if line.strip() else line for line in lines]
378
392
  indented_sql = "\n".join(indented_lines)
@@ -427,188 +441,75 @@ class SQLStringFormatter(ast.NodeVisitor):
427
441
  return ".".join(reversed(parts))
428
442
 
429
443
 
430
- def process_notebook(
431
- notebook_path: Union[str, Path],
432
- config: FormattingConfig,
433
- dialect: Optional[str],
434
- logger: logging.Logger,
435
- check_only: bool = False,
436
- ) -> bool:
437
- """Processes a Jupyter notebook.
444
+ def _has_skip_hint(source: str) -> bool:
445
+ """Check if any comment line contains the sqlnbfmt: skip directive.
438
446
 
439
- Args:
440
- check_only: If True, detect changes but don't write the file.
441
-
442
- Returns:
443
- True if changes were made (or would be made in check mode).
447
+ Only dedicated comment lines are recognised (the line must start with ``#``
448
+ after stripping whitespace). Inline trailing comments like
449
+ ``x = 1 # sqlnbfmt: skip`` are intentionally ignored so that the directive
450
+ cannot be triggered accidentally from within an expression or string.
444
451
  """
445
- try:
446
- notebook = nbformat.read(notebook_path, as_version=4)
447
- changed = False
448
-
449
- for cell_index, cell in enumerate(notebook.cells):
450
- if cell.cell_type != "code":
451
- continue
452
-
453
- original_code = cell.source
454
- if not original_code.strip():
455
- continue
456
-
457
- if "# sqlnbfmt: skip" in original_code:
458
- logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
459
- continue
460
-
461
- lines = original_code.split("\n")
462
-
463
- # Initialize variables to track magic commands
464
- magic_cmd = None
465
- magic_cmd_index = None
466
-
467
- # Iterate through lines to find the first non-comment magic command
468
- for idx, line in enumerate(lines):
469
- stripped = line.strip()
470
-
471
- if not stripped:
472
- continue # Skip empty lines
473
-
474
- if stripped.startswith("#"):
475
- continue # Skip comment lines
476
-
477
- if stripped.startswith("%%sql") or stripped.startswith("%sql"):
478
- magic_cmd = stripped.split()[0]
479
- magic_cmd_index = idx
480
- break # Magic command found
481
-
482
- else:
483
- break # Non-magic, non-comment line found
484
-
485
- if magic_cmd:
486
- is_cell_magic = magic_cmd.startswith("%%sql")
487
-
488
- if is_cell_magic:
489
- # Cell magic: SQL code starts from the next line
490
- sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
491
- else:
492
- # Line magic: SQL code is on the same line after the magic command
493
- sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
494
-
495
- try:
496
- formatted_sql = format_sql_code(
497
- sql_code,
498
- dialect,
499
- config,
500
- is_magic_command=True,
501
- is_cell_magic=is_cell_magic,
502
- )
503
-
504
- # Reconstruct the cell content
505
- if is_cell_magic:
506
- # Preserve comments before the magic command
507
- pre_magic = "\n".join(lines[:magic_cmd_index])
508
- if pre_magic:
509
- new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
510
- else:
511
- new_content = f"{magic_cmd}\n{formatted_sql}"
512
- else:
513
- # Preserve comments before the magic command
514
- pre_magic = "\n".join(lines[:magic_cmd_index])
515
- if pre_magic:
516
- new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
517
- else:
518
- new_content = f"{magic_cmd} {formatted_sql}"
519
-
520
- if new_content != original_code:
521
- cell.source = new_content
522
- changed = True
523
-
524
- except SQLFormattingError as e:
525
- logger.warning(
526
- f"[Cell {cell_index}] SQL magic formatting skipped: {e}"
527
- )
528
-
529
- continue # Move to the next cell after handling magic command
530
-
531
- # Handle regular Python code with surgical string replacement
532
- try:
533
- tree = ast.parse(original_code)
534
- line_offsets = build_line_offsets(original_code)
535
- formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
536
- formatter.visit(tree)
537
- if formatter.replacements:
538
- result = original_code
539
- for rep in sorted(
540
- formatter.replacements,
541
- key=lambda r: r.start_offset,
542
- reverse=True,
543
- ):
544
- result = (
545
- result[: rep.start_offset]
546
- + rep.new_text
547
- + result[rep.end_offset :]
548
- )
549
- if result != original_code:
550
- cell.source = result
551
- changed = True
552
- except SyntaxError:
553
- logger.warning(
554
- f"[Cell {cell_index}] Failed to parse cell:\n{original_code}"
555
- )
556
- continue
557
-
558
- if changed and not check_only:
559
- nbformat.write(notebook, notebook_path)
560
- logger.info(f"Updated notebook: {notebook_path}")
561
-
562
- return changed
563
-
564
- except Exception as e:
565
- logger.error(f"Failed to process notebook {notebook_path}: {e}", exc_info=True)
566
- raise
452
+ return any(
453
+ line.strip().startswith("#") and "sqlnbfmt: skip" in line
454
+ for line in source.split("\n")
455
+ )
567
456
 
568
457
 
569
- def diff_notebook(
570
- notebook_path: Union[str, Path],
458
+ def _format_cells(
459
+ notebook: nbformat.NotebookNode,
571
460
  config: FormattingConfig,
572
461
  dialect: Optional[str],
573
462
  logger: logging.Logger,
574
- ) -> str:
575
- """Returns a unified diff of formatting changes for a notebook."""
576
- notebook_path = Path(notebook_path)
577
- original_nb = nbformat.read(notebook_path, as_version=4)
578
- formatted_nb = copy.deepcopy(original_nb)
463
+ ) -> bool:
464
+ """Format cells in-place. Returns True if any cell was modified."""
465
+ changed = False
579
466
 
580
- # Format the copy in-memory by running the same cell logic
581
- for cell_index, cell in enumerate(formatted_nb.cells):
467
+ for cell_index, cell in enumerate(notebook.cells):
582
468
  if cell.cell_type != "code":
583
469
  continue
470
+
584
471
  original_code = cell.source
585
472
  if not original_code.strip():
586
473
  continue
587
- if "# sqlnbfmt: skip" in original_code:
474
+
475
+ if _has_skip_hint(original_code):
476
+ logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
588
477
  continue
589
478
 
590
479
  lines = original_code.split("\n")
480
+
481
+ # Initialize variables to track magic commands
591
482
  magic_cmd = None
592
483
  magic_cmd_index = None
484
+
485
+ # Iterate through lines to find the first non-comment magic command
593
486
  for idx, line in enumerate(lines):
594
487
  stripped = line.strip()
488
+
595
489
  if not stripped:
596
- continue
490
+ continue # Skip empty lines
491
+
597
492
  if stripped.startswith("#"):
598
- continue
493
+ continue # Skip comment lines
494
+
599
495
  if stripped.startswith("%%sql") or stripped.startswith("%sql"):
600
496
  magic_cmd = stripped.split()[0]
601
497
  magic_cmd_index = idx
602
- break
498
+ break # Magic command found
499
+
603
500
  else:
604
- break
501
+ break # Non-magic, non-comment line found
605
502
 
606
503
  if magic_cmd:
607
504
  is_cell_magic = magic_cmd.startswith("%%sql")
505
+
608
506
  if is_cell_magic:
507
+ # Cell magic: SQL code starts from the next line
609
508
  sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
610
509
  else:
510
+ # Line magic: SQL code is on the same line after the magic command
611
511
  sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
512
+
612
513
  try:
613
514
  formatted_sql = format_sql_code(
614
515
  sql_code,
@@ -617,23 +518,33 @@ def diff_notebook(
617
518
  is_magic_command=True,
618
519
  is_cell_magic=is_cell_magic,
619
520
  )
521
+
522
+ # Reconstruct the cell content
620
523
  if is_cell_magic:
524
+ # Preserve comments before the magic command
621
525
  pre_magic = "\n".join(lines[:magic_cmd_index])
622
526
  if pre_magic:
623
527
  new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
624
528
  else:
625
529
  new_content = f"{magic_cmd}\n{formatted_sql}"
626
530
  else:
531
+ # Preserve comments before the magic command
627
532
  pre_magic = "\n".join(lines[:magic_cmd_index])
628
533
  if pre_magic:
629
534
  new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
630
535
  else:
631
536
  new_content = f"{magic_cmd} {formatted_sql}"
632
- cell.source = new_content
633
- except SQLFormattingError:
634
- pass
635
- continue
636
537
 
538
+ if new_content != original_code:
539
+ cell.source = new_content
540
+ changed = True
541
+
542
+ except SQLFormattingError as e:
543
+ logger.warning(f"[Cell {cell_index}] SQL magic formatting skipped: {e}")
544
+
545
+ continue # Move to the next cell after handling magic command
546
+
547
+ # Handle regular Python code with surgical string replacement
637
548
  try:
638
549
  tree = ast.parse(original_code)
639
550
  line_offsets = build_line_offsets(original_code)
@@ -651,10 +562,61 @@ def diff_notebook(
651
562
  + rep.new_text
652
563
  + result[rep.end_offset :]
653
564
  )
654
- cell.source = result
565
+ if result != original_code:
566
+ cell.source = result
567
+ changed = True
655
568
  except SyntaxError:
569
+ logger.warning(
570
+ f"[Cell {cell_index}] Failed to parse cell:\n{original_code}"
571
+ )
656
572
  continue
657
573
 
574
+ return changed
575
+
576
+
577
+ def process_notebook(
578
+ notebook_path: Union[str, Path],
579
+ config: FormattingConfig,
580
+ dialect: Optional[str],
581
+ logger: logging.Logger,
582
+ check_only: bool = False,
583
+ ) -> bool:
584
+ """Processes a Jupyter notebook.
585
+
586
+ Args:
587
+ check_only: If True, detect changes but don't write the file.
588
+
589
+ Returns:
590
+ True if changes were made (or would be made in check mode).
591
+ """
592
+ try:
593
+ notebook = nbformat.read(notebook_path, as_version=4)
594
+ changed = _format_cells(notebook, config, dialect, logger)
595
+
596
+ if changed and not check_only:
597
+ nbformat.write(notebook, notebook_path)
598
+ logger.info(f"Updated notebook: {notebook_path}")
599
+
600
+ return changed
601
+
602
+ except Exception as e:
603
+ logger.error(f"Failed to process notebook {notebook_path}: {e}", exc_info=True)
604
+ raise
605
+
606
+
607
+ def diff_notebook(
608
+ notebook_path: Union[str, Path],
609
+ config: FormattingConfig,
610
+ dialect: Optional[str],
611
+ logger: logging.Logger,
612
+ ) -> str:
613
+ """Returns a unified diff of formatting changes for a notebook."""
614
+ notebook_path = Path(notebook_path)
615
+ original_nb = nbformat.read(notebook_path, as_version=4)
616
+ formatted_nb = copy.deepcopy(original_nb)
617
+
618
+ _format_cells(formatted_nb, config, dialect, logger)
619
+
658
620
  # Build unified diff cell-by-cell
659
621
  diff_lines = []
660
622
  for i, (orig_cell, fmt_cell) in enumerate(
@@ -665,8 +627,8 @@ def diff_notebook(
665
627
  difflib.unified_diff(
666
628
  orig_cell.source.splitlines(keepends=True),
667
629
  fmt_cell.source.splitlines(keepends=True),
668
- fromfile=f"Cell [{i}] original",
669
- tofile=f"Cell [{i}] formatted",
630
+ fromfile=f"{notebook_path} Cell [{i}] original",
631
+ tofile=f"{notebook_path} Cell [{i}] formatted",
670
632
  )
671
633
  )
672
634
  return "".join(diff_lines)
@@ -715,30 +677,27 @@ def main():
715
677
  sys.exit(1)
716
678
 
717
679
  try:
718
- changed = False
719
680
  changed_files = []
720
681
 
721
682
  for notebook in args.notebooks:
722
- if args.diff:
683
+ if args.check or args.diff:
684
+ # In check/diff mode, use diff_notebook to get both the
685
+ # diff output and whether changes are needed (single pass).
723
686
  diff_output = diff_notebook(notebook, config, args.dialect, logger)
724
- if diff_output:
687
+ needs_formatting = bool(diff_output)
688
+
689
+ if args.diff and diff_output:
725
690
  print(diff_output, end="")
726
- changed = True
727
- changed_files.append(notebook)
728
691
 
729
- if args.check:
730
- if process_notebook(
731
- notebook, config, args.dialect, logger, check_only=True
732
- ):
733
- changed = True
692
+ if needs_formatting:
734
693
  changed_files.append(notebook)
735
- elif not args.diff:
694
+ else:
695
+ # Normal mode: format in place
736
696
  if process_notebook(notebook, config, args.dialect, logger):
737
- changed = True
738
697
  changed_files.append(notebook)
739
698
 
740
699
  if args.check:
741
- if changed:
700
+ if changed_files:
742
701
  logger.error(f"{len(changed_files)} notebook(s) would be reformatted:")
743
702
  for f in changed_files:
744
703
  logger.error(f" - {f}")
@@ -749,7 +708,7 @@ def main():
749
708
 
750
709
  # Print summary for normal mode
751
710
  if not args.diff:
752
- if changed:
711
+ if changed_files:
753
712
  logger.info("Changes made to the following notebooks:")
754
713
  for file in changed_files:
755
714
  logger.info(f" - {file}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: A tool to format SQL code in Jupyter notebooks.
5
5
  Author-email: Qing <qingye779@gmail.com>
6
6
  License: MIT
@@ -334,3 +334,130 @@ def test_load_config_missing_file():
334
334
 
335
335
  with pytest.raises(FileNotFoundError):
336
336
  load_config("/nonexistent/config.yaml")
337
+
338
+
339
+ def test_load_config_empty_file(tmp_path):
340
+ """load_config with empty YAML file returns defaults (not AttributeError)."""
341
+ empty_config = tmp_path / "empty.yaml"
342
+ empty_config.write_text("")
343
+ config = load_config(empty_config)
344
+ assert "SELECT" in config.sql_keywords
345
+ assert "read_sql" in config.function_names
346
+
347
+
348
+ def test_load_config_partial_override(tmp_path):
349
+ """Config file with only indent_width still gets default keywords."""
350
+ partial_config = tmp_path / "partial.yaml"
351
+ partial_config.write_text("formatting_options:\n indent_width: 2\n")
352
+ config = load_config(partial_config)
353
+ assert config.indent_width == 2
354
+ assert "SELECT" in config.sql_keywords
355
+ assert "read_sql" in config.function_names
356
+
357
+
358
+ def test_skip_hint_in_string_not_triggered(tmp_path, logger):
359
+ """Skip hint inside a string literal should NOT skip the cell."""
360
+ nb = nbf.new_notebook()
361
+ original_source = (
362
+ 'msg = "Use # sqlnbfmt: skip to disable"\n'
363
+ 'query = "select id, name from users where active = 1"'
364
+ )
365
+ nb.cells = [nbf.new_code_cell(original_source)]
366
+ nb_path = tmp_path / "skip_in_string.ipynb"
367
+ nbformat.write(nb, nb_path)
368
+
369
+ config = load_config()
370
+ changed = process_notebook(nb_path, config, "mysql", logger)
371
+ assert changed # SQL should still be formatted
372
+
373
+
374
+ def test_skip_hint_magic_cell(tmp_path, logger):
375
+ """Skip hint in a magic command cell should skip formatting."""
376
+ nb = nbf.new_notebook()
377
+ original_source = "# sqlnbfmt: skip\n%%sql\nselect * from users where active = 1"
378
+ nb.cells = [nbf.new_code_cell(original_source)]
379
+ nb_path = tmp_path / "skip_magic.ipynb"
380
+ nbformat.write(nb, nb_path)
381
+
382
+ config = load_config()
383
+ changed = process_notebook(nb_path, config, "mysql", logger)
384
+ assert not changed
385
+
386
+ result_nb = nbformat.read(nb_path, as_version=4)
387
+ assert result_nb.cells[0].source == original_source
388
+
389
+
390
+ def test_check_diff_combined(tmp_path, logger):
391
+ """--check --diff combined: no duplicate files, diff is printed."""
392
+ nb = nbf.new_notebook()
393
+ nb.cells = [
394
+ nbf.new_code_cell('query = "select id, name from users where active = 1"')
395
+ ]
396
+ nb_path = tmp_path / "combined.ipynb"
397
+ nbformat.write(nb, nb_path)
398
+
399
+ config = load_config()
400
+ # Simulate the combined logic from main()
401
+ diff_output = diff_notebook(nb_path, config, "mysql", logger)
402
+ assert diff_output # has diff
403
+ # In the refactored main(), diff_notebook is used as the single source
404
+ # of truth for both --check and --diff, so no duplication possible.
405
+
406
+
407
+ def test_main_diff_only_exits_zero(tmp_path):
408
+ """--diff alone always exits 0 even when changes are needed."""
409
+ import subprocess
410
+
411
+ nb = nbf.new_notebook()
412
+ nb.cells = [
413
+ nbf.new_code_cell('query = "select id, name from users where active = 1"')
414
+ ]
415
+ nb_path = tmp_path / "diff_exit.ipynb"
416
+ nbformat.write(nb, nb_path)
417
+
418
+ result = subprocess.run(
419
+ ["sqlnbfmt", "--diff", str(nb_path)],
420
+ capture_output=True,
421
+ text=True,
422
+ )
423
+ assert result.returncode == 0
424
+ assert "---" in result.stdout # diff was printed
425
+
426
+
427
+ def test_main_check_exits_one(tmp_path):
428
+ """--check exits 1 when formatting is needed."""
429
+ import subprocess
430
+
431
+ nb = nbf.new_notebook()
432
+ nb.cells = [
433
+ nbf.new_code_cell('query = "select id, name from users where active = 1"')
434
+ ]
435
+ nb_path = tmp_path / "check_exit.ipynb"
436
+ nbformat.write(nb, nb_path)
437
+
438
+ result = subprocess.run(
439
+ ["sqlnbfmt", "--check", str(nb_path)],
440
+ capture_output=True,
441
+ text=True,
442
+ )
443
+ assert result.returncode == 1
444
+
445
+
446
+ def test_main_check_diff_exits_one(tmp_path):
447
+ """--check --diff combined exits 1 and prints diff."""
448
+ import subprocess
449
+
450
+ nb = nbf.new_notebook()
451
+ nb.cells = [
452
+ nbf.new_code_cell('query = "select id, name from users where active = 1"')
453
+ ]
454
+ nb_path = tmp_path / "check_diff_exit.ipynb"
455
+ nbformat.write(nb, nb_path)
456
+
457
+ result = subprocess.run(
458
+ ["sqlnbfmt", "--check", "--diff", str(nb_path)],
459
+ capture_output=True,
460
+ text=True,
461
+ )
462
+ assert result.returncode == 1
463
+ assert "---" in result.stdout # diff was printed
File without changes
File without changes
File without changes
File without changes
File without changes