sqlnbfmt 0.3.0__tar.gz → 0.3.1__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.1
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.1"
4
4
  description = "A tool to format SQL code in Jupyter notebooks."
5
5
  authors = [
6
6
  { name = "Qing", email = "qingye779@gmail.com" }
@@ -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
  ),
@@ -372,7 +378,7 @@ class SQLStringFormatter(ast.NodeVisitor):
372
378
  """Constructs the replacement string literal text."""
373
379
  prefix = "f" if is_fstring else ""
374
380
  if in_function and "\n" in formatted_sql:
375
- indent = " " * 4
381
+ indent = " " * self.config.indent_width
376
382
  lines = formatted_sql.split("\n")
377
383
  indented_lines = [indent + line if line.strip() else line for line in lines]
378
384
  indented_sql = "\n".join(indented_lines)
@@ -427,188 +433,75 @@ class SQLStringFormatter(ast.NodeVisitor):
427
433
  return ".".join(reversed(parts))
428
434
 
429
435
 
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.
438
-
439
- Args:
440
- check_only: If True, detect changes but don't write the file.
436
+ def _has_skip_hint(source: str) -> bool:
437
+ """Check if any comment line contains the sqlnbfmt: skip directive.
441
438
 
442
- Returns:
443
- True if changes were made (or would be made in check mode).
439
+ Only dedicated comment lines are recognised (the line must start with ``#``
440
+ after stripping whitespace). Inline trailing comments like
441
+ ``x = 1 # sqlnbfmt: skip`` are intentionally ignored so that the directive
442
+ cannot be triggered accidentally from within an expression or string.
444
443
  """
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
444
+ return any(
445
+ line.strip().startswith("#") and "sqlnbfmt: skip" in line
446
+ for line in source.split("\n")
447
+ )
567
448
 
568
449
 
569
- def diff_notebook(
570
- notebook_path: Union[str, Path],
450
+ def _format_cells(
451
+ notebook: nbformat.NotebookNode,
571
452
  config: FormattingConfig,
572
453
  dialect: Optional[str],
573
454
  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)
455
+ ) -> bool:
456
+ """Format cells in-place. Returns True if any cell was modified."""
457
+ changed = False
579
458
 
580
- # Format the copy in-memory by running the same cell logic
581
- for cell_index, cell in enumerate(formatted_nb.cells):
459
+ for cell_index, cell in enumerate(notebook.cells):
582
460
  if cell.cell_type != "code":
583
461
  continue
462
+
584
463
  original_code = cell.source
585
464
  if not original_code.strip():
586
465
  continue
587
- if "# sqlnbfmt: skip" in original_code:
466
+
467
+ if _has_skip_hint(original_code):
468
+ logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
588
469
  continue
589
470
 
590
471
  lines = original_code.split("\n")
472
+
473
+ # Initialize variables to track magic commands
591
474
  magic_cmd = None
592
475
  magic_cmd_index = None
476
+
477
+ # Iterate through lines to find the first non-comment magic command
593
478
  for idx, line in enumerate(lines):
594
479
  stripped = line.strip()
480
+
595
481
  if not stripped:
596
- continue
482
+ continue # Skip empty lines
483
+
597
484
  if stripped.startswith("#"):
598
- continue
485
+ continue # Skip comment lines
486
+
599
487
  if stripped.startswith("%%sql") or stripped.startswith("%sql"):
600
488
  magic_cmd = stripped.split()[0]
601
489
  magic_cmd_index = idx
602
- break
490
+ break # Magic command found
491
+
603
492
  else:
604
- break
493
+ break # Non-magic, non-comment line found
605
494
 
606
495
  if magic_cmd:
607
496
  is_cell_magic = magic_cmd.startswith("%%sql")
497
+
608
498
  if is_cell_magic:
499
+ # Cell magic: SQL code starts from the next line
609
500
  sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
610
501
  else:
502
+ # Line magic: SQL code is on the same line after the magic command
611
503
  sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
504
+
612
505
  try:
613
506
  formatted_sql = format_sql_code(
614
507
  sql_code,
@@ -617,23 +510,33 @@ def diff_notebook(
617
510
  is_magic_command=True,
618
511
  is_cell_magic=is_cell_magic,
619
512
  )
513
+
514
+ # Reconstruct the cell content
620
515
  if is_cell_magic:
516
+ # Preserve comments before the magic command
621
517
  pre_magic = "\n".join(lines[:magic_cmd_index])
622
518
  if pre_magic:
623
519
  new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
624
520
  else:
625
521
  new_content = f"{magic_cmd}\n{formatted_sql}"
626
522
  else:
523
+ # Preserve comments before the magic command
627
524
  pre_magic = "\n".join(lines[:magic_cmd_index])
628
525
  if pre_magic:
629
526
  new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
630
527
  else:
631
528
  new_content = f"{magic_cmd} {formatted_sql}"
632
- cell.source = new_content
633
- except SQLFormattingError:
634
- pass
635
- continue
636
529
 
530
+ if new_content != original_code:
531
+ cell.source = new_content
532
+ changed = True
533
+
534
+ except SQLFormattingError as e:
535
+ logger.warning(f"[Cell {cell_index}] SQL magic formatting skipped: {e}")
536
+
537
+ continue # Move to the next cell after handling magic command
538
+
539
+ # Handle regular Python code with surgical string replacement
637
540
  try:
638
541
  tree = ast.parse(original_code)
639
542
  line_offsets = build_line_offsets(original_code)
@@ -651,10 +554,61 @@ def diff_notebook(
651
554
  + rep.new_text
652
555
  + result[rep.end_offset :]
653
556
  )
654
- cell.source = result
557
+ if result != original_code:
558
+ cell.source = result
559
+ changed = True
655
560
  except SyntaxError:
561
+ logger.warning(
562
+ f"[Cell {cell_index}] Failed to parse cell:\n{original_code}"
563
+ )
656
564
  continue
657
565
 
566
+ return changed
567
+
568
+
569
+ def process_notebook(
570
+ notebook_path: Union[str, Path],
571
+ config: FormattingConfig,
572
+ dialect: Optional[str],
573
+ logger: logging.Logger,
574
+ check_only: bool = False,
575
+ ) -> bool:
576
+ """Processes a Jupyter notebook.
577
+
578
+ Args:
579
+ check_only: If True, detect changes but don't write the file.
580
+
581
+ Returns:
582
+ True if changes were made (or would be made in check mode).
583
+ """
584
+ try:
585
+ notebook = nbformat.read(notebook_path, as_version=4)
586
+ changed = _format_cells(notebook, config, dialect, logger)
587
+
588
+ if changed and not check_only:
589
+ nbformat.write(notebook, notebook_path)
590
+ logger.info(f"Updated notebook: {notebook_path}")
591
+
592
+ return changed
593
+
594
+ except Exception as e:
595
+ logger.error(f"Failed to process notebook {notebook_path}: {e}", exc_info=True)
596
+ raise
597
+
598
+
599
+ def diff_notebook(
600
+ notebook_path: Union[str, Path],
601
+ config: FormattingConfig,
602
+ dialect: Optional[str],
603
+ logger: logging.Logger,
604
+ ) -> str:
605
+ """Returns a unified diff of formatting changes for a notebook."""
606
+ notebook_path = Path(notebook_path)
607
+ original_nb = nbformat.read(notebook_path, as_version=4)
608
+ formatted_nb = copy.deepcopy(original_nb)
609
+
610
+ _format_cells(formatted_nb, config, dialect, logger)
611
+
658
612
  # Build unified diff cell-by-cell
659
613
  diff_lines = []
660
614
  for i, (orig_cell, fmt_cell) in enumerate(
@@ -665,8 +619,8 @@ def diff_notebook(
665
619
  difflib.unified_diff(
666
620
  orig_cell.source.splitlines(keepends=True),
667
621
  fmt_cell.source.splitlines(keepends=True),
668
- fromfile=f"Cell [{i}] original",
669
- tofile=f"Cell [{i}] formatted",
622
+ fromfile=f"{notebook_path} Cell [{i}] original",
623
+ tofile=f"{notebook_path} Cell [{i}] formatted",
670
624
  )
671
625
  )
672
626
  return "".join(diff_lines)
@@ -715,30 +669,27 @@ def main():
715
669
  sys.exit(1)
716
670
 
717
671
  try:
718
- changed = False
719
672
  changed_files = []
720
673
 
721
674
  for notebook in args.notebooks:
722
- if args.diff:
675
+ if args.check or args.diff:
676
+ # In check/diff mode, use diff_notebook to get both the
677
+ # diff output and whether changes are needed (single pass).
723
678
  diff_output = diff_notebook(notebook, config, args.dialect, logger)
724
- if diff_output:
679
+ needs_formatting = bool(diff_output)
680
+
681
+ if args.diff and diff_output:
725
682
  print(diff_output, end="")
726
- changed = True
727
- changed_files.append(notebook)
728
683
 
729
- if args.check:
730
- if process_notebook(
731
- notebook, config, args.dialect, logger, check_only=True
732
- ):
733
- changed = True
684
+ if needs_formatting:
734
685
  changed_files.append(notebook)
735
- elif not args.diff:
686
+ else:
687
+ # Normal mode: format in place
736
688
  if process_notebook(notebook, config, args.dialect, logger):
737
- changed = True
738
689
  changed_files.append(notebook)
739
690
 
740
691
  if args.check:
741
- if changed:
692
+ if changed_files:
742
693
  logger.error(f"{len(changed_files)} notebook(s) would be reformatted:")
743
694
  for f in changed_files:
744
695
  logger.error(f" - {f}")
@@ -749,7 +700,7 @@ def main():
749
700
 
750
701
  # Print summary for normal mode
751
702
  if not args.diff:
752
- if changed:
703
+ if changed_files:
753
704
  logger.info("Changes made to the following notebooks:")
754
705
  for file in changed_files:
755
706
  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.1
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