sqlnbfmt 0.4.0__tar.gz → 0.4.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.4.0
3
+ Version: 0.4.1
4
4
  Summary: A tool to format SQL code in Jupyter notebooks and Marimo notebooks.
5
5
  Author-email: Qing <qingye779@gmail.com>
6
6
  License: MIT
@@ -48,6 +48,7 @@ A SQL formatter for Jupyter Notebooks and Marimo notebooks. `sqlnbfmt` automatic
48
48
  - **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
49
49
  - **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
50
50
  - **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
51
+ - **JupyterLab Plugin**: Works with `jupyterlab-code-formatter` for interactive format-on-save
51
52
  - **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
52
53
 
53
54
  ## Installation
@@ -94,6 +95,23 @@ Add a `# sqlnbfmt: skip` comment to skip formatting. In Jupyter notebooks this a
94
95
  query = "select * from my_special_table where id = 1"
95
96
  ```
96
97
 
98
+ ### JupyterLab Integration
99
+
100
+ Format SQL cells interactively with [jupyterlab-code-formatter](https://github.com/jupyterlab-contrib/jupyterlab_code_formatter):
101
+
102
+ 1. Install both packages:
103
+ ```bash
104
+ pip install sqlnbfmt jupyterlab-code-formatter
105
+ ```
106
+
107
+ 2. Register the formatter in `~/.jupyter/jupyter_notebook_config.py`:
108
+ ```python
109
+ from sqlnbfmt.jupyterlab_integration import register
110
+ register() # or: register(dialect="postgres")
111
+ ```
112
+
113
+ 3. In JupyterLab, use **Edit > Format Cell** or configure format-on-save in Settings > Code Formatter.
114
+
97
115
  ### Pre-commit Integration
98
116
 
99
117
  1. Install pre-commit:
@@ -19,6 +19,7 @@ A SQL formatter for Jupyter Notebooks and Marimo notebooks. `sqlnbfmt` automatic
19
19
  - **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
20
20
  - **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
21
21
  - **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
22
+ - **JupyterLab Plugin**: Works with `jupyterlab-code-formatter` for interactive format-on-save
22
23
  - **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
23
24
 
24
25
  ## Installation
@@ -65,6 +66,23 @@ Add a `# sqlnbfmt: skip` comment to skip formatting. In Jupyter notebooks this a
65
66
  query = "select * from my_special_table where id = 1"
66
67
  ```
67
68
 
69
+ ### JupyterLab Integration
70
+
71
+ Format SQL cells interactively with [jupyterlab-code-formatter](https://github.com/jupyterlab-contrib/jupyterlab_code_formatter):
72
+
73
+ 1. Install both packages:
74
+ ```bash
75
+ pip install sqlnbfmt jupyterlab-code-formatter
76
+ ```
77
+
78
+ 2. Register the formatter in `~/.jupyter/jupyter_notebook_config.py`:
79
+ ```python
80
+ from sqlnbfmt.jupyterlab_integration import register
81
+ register() # or: register(dialect="postgres")
82
+ ```
83
+
84
+ 3. In JupyterLab, use **Edit > Format Cell** or configure format-on-save in Settings > Code Formatter.
85
+
68
86
  ### Pre-commit Integration
69
87
 
70
88
  1. Install pre-commit:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlnbfmt"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "A tool to format SQL code in Jupyter notebooks and Marimo notebooks."
5
5
  authors = [
6
6
  { name = "Qing", email = "qingye779@gmail.com" }
@@ -457,122 +457,119 @@ def _has_skip_hint(source: str) -> bool:
457
457
  )
458
458
 
459
459
 
460
- def _format_cells(
461
- notebook: nbformat.NotebookNode,
460
+ def format_cell_source(
461
+ code: str,
462
462
  config: FormattingConfig,
463
463
  dialect: Optional[str],
464
464
  logger: logging.Logger,
465
- ) -> bool:
466
- """Format cells in-place. Returns True if any cell was modified."""
467
- changed = False
465
+ ) -> str:
466
+ """Format SQL in a single cell's source code.
468
467
 
469
- for cell_index, cell in enumerate(notebook.cells):
470
- if cell.cell_type != "code":
471
- continue
468
+ Handles both magic commands (%%sql, %sql) and regular Python code
469
+ with embedded SQL strings. Returns the formatted source.
470
+ """
471
+ if not code.strip():
472
+ return code
472
473
 
473
- original_code = cell.source
474
- if not original_code.strip():
475
- continue
474
+ if _has_skip_hint(code):
475
+ return code
476
476
 
477
- if _has_skip_hint(original_code):
478
- logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
479
- continue
477
+ lines = code.split("\n")
480
478
 
481
- lines = original_code.split("\n")
479
+ # Look for magic commands
480
+ magic_cmd = None
481
+ magic_cmd_index = None
482
482
 
483
- # Initialize variables to track magic commands
484
- magic_cmd = None
485
- magic_cmd_index = None
483
+ for idx, line in enumerate(lines):
484
+ stripped = line.strip()
486
485
 
487
- # Iterate through lines to find the first non-comment magic command
488
- for idx, line in enumerate(lines):
489
- stripped = line.strip()
486
+ if not stripped:
487
+ continue
488
+ if stripped.startswith("#"):
489
+ continue
490
+ if stripped.startswith("%%sql") or stripped.startswith("%sql"):
491
+ magic_cmd = stripped.split()[0]
492
+ magic_cmd_index = idx
493
+ break
494
+ else:
495
+ break
490
496
 
491
- if not stripped:
492
- continue # Skip empty lines
497
+ if magic_cmd:
498
+ is_cell_magic = magic_cmd.startswith("%%sql")
493
499
 
494
- if stripped.startswith("#"):
495
- continue # Skip comment lines
500
+ if is_cell_magic:
501
+ sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
502
+ else:
503
+ sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
496
504
 
497
- if stripped.startswith("%%sql") or stripped.startswith("%sql"):
498
- magic_cmd = stripped.split()[0]
499
- magic_cmd_index = idx
500
- break # Magic command found
505
+ try:
506
+ formatted_sql = format_sql_code(
507
+ sql_code,
508
+ dialect,
509
+ config,
510
+ is_magic_command=True,
511
+ is_cell_magic=is_cell_magic,
512
+ )
501
513
 
514
+ if is_cell_magic:
515
+ pre_magic = "\n".join(lines[:magic_cmd_index])
516
+ if pre_magic:
517
+ return f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
518
+ return f"{magic_cmd}\n{formatted_sql}"
502
519
  else:
503
- break # Non-magic, non-comment line found
520
+ pre_magic = "\n".join(lines[:magic_cmd_index])
521
+ if pre_magic:
522
+ return f"{pre_magic}\n{magic_cmd} {formatted_sql}"
523
+ return f"{magic_cmd} {formatted_sql}"
504
524
 
505
- if magic_cmd:
506
- is_cell_magic = magic_cmd.startswith("%%sql")
525
+ except SQLFormattingError as e:
526
+ logger.warning(f"SQL magic formatting skipped: {e}")
527
+ return code
507
528
 
508
- if is_cell_magic:
509
- # Cell magic: SQL code starts from the next line
510
- sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
511
- else:
512
- # Line magic: SQL code is on the same line after the magic command
513
- sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
529
+ # Handle regular Python code with surgical string replacement
530
+ try:
531
+ tree = ast.parse(code)
532
+ except SyntaxError:
533
+ logger.warning(f"Failed to parse cell:\n{code}")
534
+ return code
514
535
 
515
- try:
516
- formatted_sql = format_sql_code(
517
- sql_code,
518
- dialect,
519
- config,
520
- is_magic_command=True,
521
- is_cell_magic=is_cell_magic,
522
- )
536
+ line_offsets = build_line_offsets(code)
537
+ formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
538
+ formatter.visit(tree)
523
539
 
524
- # Reconstruct the cell content
525
- if is_cell_magic:
526
- # Preserve comments before the magic command
527
- pre_magic = "\n".join(lines[:magic_cmd_index])
528
- if pre_magic:
529
- new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
530
- else:
531
- new_content = f"{magic_cmd}\n{formatted_sql}"
532
- else:
533
- # Preserve comments before the magic command
534
- pre_magic = "\n".join(lines[:magic_cmd_index])
535
- if pre_magic:
536
- new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
537
- else:
538
- new_content = f"{magic_cmd} {formatted_sql}"
540
+ if not formatter.replacements:
541
+ return code
539
542
 
540
- if new_content != original_code:
541
- cell.source = new_content
542
- changed = True
543
+ result = code
544
+ for rep in sorted(
545
+ formatter.replacements,
546
+ key=lambda r: r.start_offset,
547
+ reverse=True,
548
+ ):
549
+ result = result[: rep.start_offset] + rep.new_text + result[rep.end_offset :]
543
550
 
544
- except SQLFormattingError as e:
545
- logger.warning(f"[Cell {cell_index}] SQL magic formatting skipped: {e}")
551
+ return result
546
552
 
547
- continue # Move to the next cell after handling magic command
548
553
 
549
- # Handle regular Python code with surgical string replacement
550
- try:
551
- tree = ast.parse(original_code)
552
- line_offsets = build_line_offsets(original_code)
553
- formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
554
- formatter.visit(tree)
555
- if formatter.replacements:
556
- result = original_code
557
- for rep in sorted(
558
- formatter.replacements,
559
- key=lambda r: r.start_offset,
560
- reverse=True,
561
- ):
562
- result = (
563
- result[: rep.start_offset]
564
- + rep.new_text
565
- + result[rep.end_offset :]
566
- )
567
- if result != original_code:
568
- cell.source = result
569
- changed = True
570
- except SyntaxError:
571
- logger.warning(
572
- f"[Cell {cell_index}] Failed to parse cell:\n{original_code}"
573
- )
554
+ def _format_cells(
555
+ notebook: nbformat.NotebookNode,
556
+ config: FormattingConfig,
557
+ dialect: Optional[str],
558
+ logger: logging.Logger,
559
+ ) -> bool:
560
+ """Format cells in-place. Returns True if any cell was modified."""
561
+ changed = False
562
+
563
+ for cell in notebook.cells:
564
+ if cell.cell_type != "code":
574
565
  continue
575
566
 
567
+ original_code = cell.source
568
+ formatted = format_cell_source(original_code, config, dialect, logger)
569
+ if formatted != original_code:
570
+ cell.source = formatted
571
+ changed = True
572
+
576
573
  return changed
577
574
 
578
575
 
@@ -0,0 +1,74 @@
1
+ """JupyterLab code formatter integration.
2
+
3
+ Provides a formatter plugin for jupyterlab-code-formatter that formats
4
+ SQL in notebook cells (both magic commands and Python strings).
5
+
6
+ Setup: add this to your ``jupyter_notebook_config.py``::
7
+
8
+ from sqlnbfmt.jupyterlab_integration import register
9
+ register()
10
+
11
+ Then select "Apply SQL Notebook Formatter" in JupyterLab's
12
+ Code > Format Cell menu or configure it for format-on-save.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import Optional
19
+
20
+
21
+ def _get_formatter_class():
22
+ """Build the formatter class lazily to avoid hard dependency."""
23
+ from jupyterlab_code_formatter.formatters import BaseFormatter
24
+
25
+ from sqlnbfmt.formatter import FormattingConfig, format_cell_source, load_config
26
+
27
+ class SQLNotebookFormatter(BaseFormatter):
28
+ label = "Apply SQL Notebook Formatter"
29
+
30
+ def __init__(
31
+ self,
32
+ config: Optional[FormattingConfig] = None,
33
+ dialect: Optional[str] = None,
34
+ ):
35
+ self._config = config or load_config()
36
+ self._dialect = dialect
37
+ self._logger = logging.getLogger("sqlnbfmt.jupyterlab")
38
+
39
+ @property
40
+ def importable(self) -> bool:
41
+ return True
42
+
43
+ def format_code(self, code: str, notebook: bool, **options) -> str:
44
+ dialect = options.pop("dialect", self._dialect)
45
+ return format_cell_source(code, self._config, dialect, self._logger)
46
+
47
+ return SQLNotebookFormatter
48
+
49
+
50
+ def register(
51
+ name: str = "sqlnbfmt",
52
+ dialect: Optional[str] = None,
53
+ config_path: Optional[str] = None,
54
+ ) -> None:
55
+ """Register sqlnbfmt with jupyterlab-code-formatter.
56
+
57
+ Call this in ``jupyter_notebook_config.py``::
58
+
59
+ from sqlnbfmt.jupyterlab_integration import register
60
+ register() # defaults
61
+ register(dialect="postgres") # with options
62
+
63
+ Args:
64
+ name: Key used in JupyterLab settings to reference this formatter.
65
+ dialect: SQL dialect (e.g. ``"postgres"``, ``"mysql"``).
66
+ config_path: Path to a sqlnbfmt YAML config file.
67
+ """
68
+ from jupyterlab_code_formatter.formatters import SERVER_FORMATTERS
69
+
70
+ from sqlnbfmt.formatter import load_config
71
+
72
+ config = load_config(config_path)
73
+ cls = _get_formatter_class()
74
+ SERVER_FORMATTERS[name] = cls(config=config, dialect=dialect)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: A tool to format SQL code in Jupyter notebooks and Marimo notebooks.
5
5
  Author-email: Qing <qingye779@gmail.com>
6
6
  License: MIT
@@ -48,6 +48,7 @@ A SQL formatter for Jupyter Notebooks and Marimo notebooks. `sqlnbfmt` automatic
48
48
  - **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
49
49
  - **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
50
50
  - **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
51
+ - **JupyterLab Plugin**: Works with `jupyterlab-code-formatter` for interactive format-on-save
51
52
  - **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
52
53
 
53
54
  ## Installation
@@ -94,6 +95,23 @@ Add a `# sqlnbfmt: skip` comment to skip formatting. In Jupyter notebooks this a
94
95
  query = "select * from my_special_table where id = 1"
95
96
  ```
96
97
 
98
+ ### JupyterLab Integration
99
+
100
+ Format SQL cells interactively with [jupyterlab-code-formatter](https://github.com/jupyterlab-contrib/jupyterlab_code_formatter):
101
+
102
+ 1. Install both packages:
103
+ ```bash
104
+ pip install sqlnbfmt jupyterlab-code-formatter
105
+ ```
106
+
107
+ 2. Register the formatter in `~/.jupyter/jupyter_notebook_config.py`:
108
+ ```python
109
+ from sqlnbfmt.jupyterlab_integration import register
110
+ register() # or: register(dialect="postgres")
111
+ ```
112
+
113
+ 3. In JupyterLab, use **Edit > Format Cell** or configure format-on-save in Settings > Code Formatter.
114
+
97
115
  ### Pre-commit Integration
98
116
 
99
117
  1. Install pre-commit:
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  sqlnbfmt/__init__.py
5
5
  sqlnbfmt/formatter.py
6
+ sqlnbfmt/jupyterlab_integration.py
6
7
  sqlnbfmt.egg-info/PKG-INFO
7
8
  sqlnbfmt.egg-info/SOURCES.txt
8
9
  sqlnbfmt.egg-info/dependency_links.txt
@@ -11,4 +12,5 @@ sqlnbfmt.egg-info/requires.txt
11
12
  sqlnbfmt.egg-info/top_level.txt
12
13
  tests/test_eval.py
13
14
  tests/test_formatter.py
15
+ tests/test_jupyterlab_integration.py
14
16
  tests/test_marimo.py
@@ -3,7 +3,12 @@ import nbformat
3
3
  from nbformat import v4 as nbf
4
4
  import logging
5
5
 
6
- from sqlnbfmt.formatter import process_notebook, load_config, diff_notebook
6
+ from sqlnbfmt.formatter import (
7
+ format_cell_source,
8
+ process_notebook,
9
+ load_config,
10
+ diff_notebook,
11
+ )
7
12
 
8
13
 
9
14
  # Fixture for temporary notebook path
@@ -461,3 +466,50 @@ def test_main_check_diff_exits_one(tmp_path):
461
466
  )
462
467
  assert result.returncode == 1
463
468
  assert "---" in result.stdout # diff was printed
469
+
470
+
471
+ # ---------------------------------------------------------------------------
472
+ # format_cell_source tests
473
+ # ---------------------------------------------------------------------------
474
+
475
+
476
+ def test_format_cell_source_magic(logger):
477
+ """format_cell_source handles %%sql magic cells."""
478
+ config = load_config()
479
+ code = "%%sql\nselect id, name from users where active = 1"
480
+ result = format_cell_source(code, config, "mysql", logger)
481
+ assert result.startswith("%%sql\n")
482
+ assert "SELECT" in result
483
+
484
+
485
+ def test_format_cell_source_line_magic(logger):
486
+ """format_cell_source handles %sql line magic."""
487
+ config = load_config()
488
+ code = "%sql select * from users where active = 1"
489
+ result = format_cell_source(code, config, "mysql", logger)
490
+ assert result.startswith("%sql ")
491
+ assert "SELECT" in result
492
+
493
+
494
+ def test_format_cell_source_python(logger):
495
+ """format_cell_source handles regular Python code with SQL."""
496
+ config = load_config()
497
+ code = 'query = "select id, name from users where active = 1"'
498
+ result = format_cell_source(code, config, "mysql", logger)
499
+ assert "SELECT" in result
500
+
501
+
502
+ def test_format_cell_source_non_sql(logger):
503
+ """format_cell_source returns non-SQL code unchanged."""
504
+ config = load_config()
505
+ code = "x = 42\nprint(x)"
506
+ result = format_cell_source(code, config, "mysql", logger)
507
+ assert result == code
508
+
509
+
510
+ def test_format_cell_source_skip_hint(logger):
511
+ """format_cell_source respects skip hints."""
512
+ config = load_config()
513
+ code = '# sqlnbfmt: skip\nquery = "select * from users"'
514
+ result = format_cell_source(code, config, "mysql", logger)
515
+ assert result == code
@@ -0,0 +1,76 @@
1
+ """Tests for the JupyterLab code formatter integration.
2
+
3
+ Since jupyterlab-code-formatter is not a dependency, these tests
4
+ mock the necessary imports and verify the adapter works correctly.
5
+ """
6
+
7
+ from unittest.mock import MagicMock, patch
8
+
9
+
10
+ def test_register_adds_to_server_formatters():
11
+ """register() inserts an instance into SERVER_FORMATTERS."""
12
+ mock_formatters = {}
13
+ mock_base = type("BaseFormatter", (), {})
14
+
15
+ with (
16
+ patch.dict(
17
+ "sys.modules",
18
+ {
19
+ "jupyterlab_code_formatter": MagicMock(),
20
+ "jupyterlab_code_formatter.formatters": MagicMock(
21
+ BaseFormatter=mock_base,
22
+ SERVER_FORMATTERS=mock_formatters,
23
+ ),
24
+ },
25
+ ),
26
+ ):
27
+ from sqlnbfmt.jupyterlab_integration import register
28
+
29
+ register()
30
+ assert "sqlnbfmt" in mock_formatters
31
+
32
+ register(name="custom_name", dialect="postgres")
33
+ assert "custom_name" in mock_formatters
34
+
35
+
36
+ def test_formatter_class_format_code():
37
+ """The formatter class delegates to format_cell_source."""
38
+ mock_base = type("BaseFormatter", (), {})
39
+
40
+ with patch.dict(
41
+ "sys.modules",
42
+ {
43
+ "jupyterlab_code_formatter": MagicMock(),
44
+ "jupyterlab_code_formatter.formatters": MagicMock(
45
+ BaseFormatter=mock_base,
46
+ SERVER_FORMATTERS={},
47
+ ),
48
+ },
49
+ ):
50
+ from sqlnbfmt.jupyterlab_integration import _get_formatter_class
51
+
52
+ cls = _get_formatter_class()
53
+ formatter = cls()
54
+
55
+ assert formatter.label == "Apply SQL Notebook Formatter"
56
+ assert formatter.importable is True
57
+
58
+ # Format a cell with SQL
59
+ result = formatter.format_code(
60
+ 'query = "select id, name from users where active = 1"',
61
+ notebook=True,
62
+ )
63
+ assert "SELECT" in result
64
+
65
+ # Format a %%sql magic cell
66
+ result = formatter.format_code(
67
+ "%%sql\nselect * from users where active = 1",
68
+ notebook=True,
69
+ )
70
+ assert "%%sql" in result
71
+ assert "SELECT" in result
72
+
73
+ # Non-SQL code passes through unchanged
74
+ code = "x = 42\nprint(x)"
75
+ result = formatter.format_code(code, notebook=True)
76
+ assert result == code
File without changes
File without changes
File without changes
File without changes
File without changes