sqlnbfmt 0.2.0__tar.gz → 0.3.0__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,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
+ Project-URL: Homepage, https://github.com/flyersworder/sqlnbfmt
8
+ Project-URL: Repository, https://github.com/flyersworder/sqlnbfmt
9
+ Project-URL: Changelog, https://github.com/flyersworder/sqlnbfmt/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/flyersworder/sqlnbfmt/issues
11
+ Keywords: sql,jupyter,notebook,formatter,sqlglot,pre-commit
7
12
  Classifier: Programming Language :: Python :: 3
8
13
  Classifier: Programming Language :: Python :: 3.12
9
14
  Classifier: Programming Language :: Python :: 3.13
@@ -21,16 +26,20 @@ Dynamic: license-file
21
26
  [![License](https://img.shields.io/pypi/l/sqlnbfmt.svg)](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
22
27
  [![Python Versions](https://img.shields.io/pypi/pyversions/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
23
28
 
29
+ > **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
30
+
24
31
  A SQL formatter designed specifically for Jupyter Notebooks. `sqlnbfmt` automatically formats SQL queries embedded in code cells, including both Python strings and SQL magic cells (`%%sql`), helping you maintain clean and consistent code.
25
32
 
26
33
  ## Features
27
34
 
28
- - 🎯 **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
29
- - 🌳 **AST-Powered**: Uses Abstract Syntax Tree parsing for accurate SQL string identification
30
- - 🔒 **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), and SQL comments
31
- - ⚙️ **Highly Configurable**: Customize formatting through YAML configuration
32
- - 🔄 **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
33
- - 📦 **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
35
+ - **Zero-config**: Works out of the box with sensible defaults — no config file needed
36
+ - **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
37
+ - **AST-Powered**: Uses Abstract Syntax Tree parsing for accurate SQL string identification
38
+ - **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), and SQL comments
39
+ - **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
40
+ - **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
41
+ - **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
42
+ - **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
34
43
 
35
44
  ## Installation
36
45
 
@@ -47,9 +56,23 @@ Format a single notebook:
47
56
  sqlnbfmt path/to/your_notebook.ipynb
48
57
  ```
49
58
 
50
- Format all notebooks in a directory:
59
+ Check formatting without modifying files (useful in CI):
60
+ ```bash
61
+ sqlnbfmt --check path/to/your_notebook.ipynb
62
+ ```
63
+
64
+ Show a diff of what would change:
51
65
  ```bash
52
- sqlnbfmt path/to/notebooks/
66
+ sqlnbfmt --diff path/to/your_notebook.ipynb
67
+ ```
68
+
69
+ ### Skipping Cells
70
+
71
+ Add a `# sqlnbfmt: skip` comment anywhere in a cell to skip formatting:
72
+
73
+ ```python
74
+ # sqlnbfmt: skip
75
+ query = "select * from my_special_table where id = 1"
53
76
  ```
54
77
 
55
78
  ### Pre-commit Integration
@@ -63,17 +86,15 @@ pip install pre-commit
63
86
  ```yaml
64
87
  repos:
65
88
  - repo: https://github.com/flyersworder/sqlnbfmt
66
- rev: v0.2.0
89
+ rev: v0.3.0
67
90
  hooks:
68
91
  - id: sqlnbfmt
69
- name: sqlnbfmt
70
92
  types: [jupyter]
71
- args: [--config, config.yaml, --dialect, postgres]
72
93
  ```
73
- Please run the following command for help:
74
94
 
75
- ```bash
76
- sqlnbfmt -h
95
+ All arguments are optional. To specify a dialect or custom config:
96
+ ```yaml
97
+ args: [--dialect, postgres, --config, config.yaml]
77
98
  ```
78
99
 
79
100
  3. Install the hook:
@@ -86,8 +107,21 @@ pre-commit install
86
107
  pre-commit run --all-files
87
108
  ```
88
109
 
110
+ ### CI Usage
111
+
112
+ Use `--check` in GitHub Actions to enforce formatting:
113
+
114
+ ```yaml
115
+ - name: Check SQL notebook formatting
116
+ run: |
117
+ pip install sqlnbfmt
118
+ sqlnbfmt --check **/*.ipynb
119
+ ```
120
+
89
121
  ## Configuration
90
122
 
123
+ `sqlnbfmt` works without any configuration file. A `config.yaml` is only needed to override defaults.
124
+
91
125
  Create a `config.yaml` file to customize formatting behavior. [Here](https://github.com/flyersworder/sqlnbfmt/blob/main/config.yaml) is a template.
92
126
 
93
127
  ### Configuration Options
@@ -95,8 +129,8 @@ Create a `config.yaml` file to customize formatting behavior. [Here](https://git
95
129
  | Option | Description | Default |
96
130
  |--------|-------------|---------|
97
131
  | `sql_keywords` | SQL keywords to recognize and format | Common SQL keywords |
98
- | `function_names` | Python functions containing SQL code | `[]` |
99
- | `sql_decorators` | Decorators indicating SQL code | `[]` |
132
+ | `function_names` | Python functions containing SQL code | `read_sql`, `execute`, etc. |
133
+ | `sql_decorators` | Decorators indicating SQL code | `query`, `sql_query`, etc. |
100
134
  | `single_line_threshold` | Maximum length before splitting SQL | 80 |
101
135
  | `indent_width` | Number of spaces for indentation | 4 |
102
136
 
@@ -125,6 +159,19 @@ ORDER BY
125
159
  """)
126
160
  ```
127
161
 
162
+ ## Troubleshooting
163
+
164
+ **SQL not being formatted?**
165
+ - Ensure the string contains at least 2 SQL keywords or a recognizable pattern like `SELECT...FROM`
166
+ - Check that the function name is in the recognized list (use `--config` to add custom ones)
167
+
168
+ **Comments being modified?**
169
+ - Python comments (`#`) are preserved. SQL comments (`--`) inside strings are converted to `/* */` block comments by sqlglot.
170
+
171
+ **Pre-commit hook fails?**
172
+ - Make sure the `rev` matches the installed version
173
+ - Run `pre-commit autoupdate` to get the latest version
174
+
128
175
  ## Contributing
129
176
 
130
177
  We welcome contributions! Here's how to get started:
@@ -146,6 +193,13 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate
146
193
  pytest
147
194
  ```
148
195
 
196
+ 4. Add eval cases: see `tests/eval/generate_fixtures.py` for examples. Run `python tests/eval/generate_fixtures.py` to regenerate fixtures.
197
+
198
+ 5. Install dev pre-commit hooks:
199
+ ```bash
200
+ pre-commit install
201
+ ```
202
+
149
203
  ## License
150
204
 
151
205
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -4,16 +4,20 @@
4
4
  [![License](https://img.shields.io/pypi/l/sqlnbfmt.svg)](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
5
5
  [![Python Versions](https://img.shields.io/pypi/pyversions/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
6
6
 
7
+ > **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
8
+
7
9
  A SQL formatter designed specifically for Jupyter Notebooks. `sqlnbfmt` automatically formats SQL queries embedded in code cells, including both Python strings and SQL magic cells (`%%sql`), helping you maintain clean and consistent code.
8
10
 
9
11
  ## Features
10
12
 
11
- - 🎯 **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
12
- - 🌳 **AST-Powered**: Uses Abstract Syntax Tree parsing for accurate SQL string identification
13
- - 🔒 **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), and SQL comments
14
- - ⚙️ **Highly Configurable**: Customize formatting through YAML configuration
15
- - 🔄 **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
16
- - 📦 **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
13
+ - **Zero-config**: Works out of the box with sensible defaults — no config file needed
14
+ - **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
15
+ - **AST-Powered**: Uses Abstract Syntax Tree parsing for accurate SQL string identification
16
+ - **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), and SQL comments
17
+ - **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
18
+ - **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
19
+ - **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
20
+ - **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
17
21
 
18
22
  ## Installation
19
23
 
@@ -30,9 +34,23 @@ Format a single notebook:
30
34
  sqlnbfmt path/to/your_notebook.ipynb
31
35
  ```
32
36
 
33
- Format all notebooks in a directory:
37
+ Check formatting without modifying files (useful in CI):
38
+ ```bash
39
+ sqlnbfmt --check path/to/your_notebook.ipynb
40
+ ```
41
+
42
+ Show a diff of what would change:
34
43
  ```bash
35
- sqlnbfmt path/to/notebooks/
44
+ sqlnbfmt --diff path/to/your_notebook.ipynb
45
+ ```
46
+
47
+ ### Skipping Cells
48
+
49
+ Add a `# sqlnbfmt: skip` comment anywhere in a cell to skip formatting:
50
+
51
+ ```python
52
+ # sqlnbfmt: skip
53
+ query = "select * from my_special_table where id = 1"
36
54
  ```
37
55
 
38
56
  ### Pre-commit Integration
@@ -46,17 +64,15 @@ pip install pre-commit
46
64
  ```yaml
47
65
  repos:
48
66
  - repo: https://github.com/flyersworder/sqlnbfmt
49
- rev: v0.2.0
67
+ rev: v0.3.0
50
68
  hooks:
51
69
  - id: sqlnbfmt
52
- name: sqlnbfmt
53
70
  types: [jupyter]
54
- args: [--config, config.yaml, --dialect, postgres]
55
71
  ```
56
- Please run the following command for help:
57
72
 
58
- ```bash
59
- sqlnbfmt -h
73
+ All arguments are optional. To specify a dialect or custom config:
74
+ ```yaml
75
+ args: [--dialect, postgres, --config, config.yaml]
60
76
  ```
61
77
 
62
78
  3. Install the hook:
@@ -69,8 +85,21 @@ pre-commit install
69
85
  pre-commit run --all-files
70
86
  ```
71
87
 
88
+ ### CI Usage
89
+
90
+ Use `--check` in GitHub Actions to enforce formatting:
91
+
92
+ ```yaml
93
+ - name: Check SQL notebook formatting
94
+ run: |
95
+ pip install sqlnbfmt
96
+ sqlnbfmt --check **/*.ipynb
97
+ ```
98
+
72
99
  ## Configuration
73
100
 
101
+ `sqlnbfmt` works without any configuration file. A `config.yaml` is only needed to override defaults.
102
+
74
103
  Create a `config.yaml` file to customize formatting behavior. [Here](https://github.com/flyersworder/sqlnbfmt/blob/main/config.yaml) is a template.
75
104
 
76
105
  ### Configuration Options
@@ -78,8 +107,8 @@ Create a `config.yaml` file to customize formatting behavior. [Here](https://git
78
107
  | Option | Description | Default |
79
108
  |--------|-------------|---------|
80
109
  | `sql_keywords` | SQL keywords to recognize and format | Common SQL keywords |
81
- | `function_names` | Python functions containing SQL code | `[]` |
82
- | `sql_decorators` | Decorators indicating SQL code | `[]` |
110
+ | `function_names` | Python functions containing SQL code | `read_sql`, `execute`, etc. |
111
+ | `sql_decorators` | Decorators indicating SQL code | `query`, `sql_query`, etc. |
83
112
  | `single_line_threshold` | Maximum length before splitting SQL | 80 |
84
113
  | `indent_width` | Number of spaces for indentation | 4 |
85
114
 
@@ -108,6 +137,19 @@ ORDER BY
108
137
  """)
109
138
  ```
110
139
 
140
+ ## Troubleshooting
141
+
142
+ **SQL not being formatted?**
143
+ - Ensure the string contains at least 2 SQL keywords or a recognizable pattern like `SELECT...FROM`
144
+ - Check that the function name is in the recognized list (use `--config` to add custom ones)
145
+
146
+ **Comments being modified?**
147
+ - Python comments (`#`) are preserved. SQL comments (`--`) inside strings are converted to `/* */` block comments by sqlglot.
148
+
149
+ **Pre-commit hook fails?**
150
+ - Make sure the `rev` matches the installed version
151
+ - Run `pre-commit autoupdate` to get the latest version
152
+
111
153
  ## Contributing
112
154
 
113
155
  We welcome contributions! Here's how to get started:
@@ -129,6 +171,13 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate
129
171
  pytest
130
172
  ```
131
173
 
174
+ 4. Add eval cases: see `tests/eval/generate_fixtures.py` for examples. Run `python tests/eval/generate_fixtures.py` to regenerate fixtures.
175
+
176
+ 5. Install dev pre-commit hooks:
177
+ ```bash
178
+ pre-commit install
179
+ ```
180
+
132
181
  ## License
133
182
 
134
183
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlnbfmt"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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,7 @@ authors = [
8
8
  license = { text = "MIT" }
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
11
+ keywords = ["sql", "jupyter", "notebook", "formatter", "sqlglot", "pre-commit"]
11
12
  classifiers = [
12
13
  "Programming Language :: Python :: 3",
13
14
  "Programming Language :: Python :: 3.12",
@@ -19,6 +20,15 @@ dependencies = [
19
20
  "sqlglot>=25.27.0",
20
21
  ]
21
22
 
23
+ [project.urls]
24
+ Homepage = "https://github.com/flyersworder/sqlnbfmt"
25
+ Repository = "https://github.com/flyersworder/sqlnbfmt"
26
+ Changelog = "https://github.com/flyersworder/sqlnbfmt/blob/main/CHANGELOG.md"
27
+ Issues = "https://github.com/flyersworder/sqlnbfmt/issues"
28
+
29
+ [project.scripts]
30
+ sqlnbfmt = "sqlnbfmt.formatter:main"
31
+
22
32
  [dependency-groups]
23
33
  dev = [
24
34
  "pytest-cov>=5.0.0",
@@ -30,9 +40,6 @@ dev = [
30
40
  requires = ["setuptools>=61.0", "wheel"]
31
41
  build-backend = "setuptools.build_meta"
32
42
 
33
- [project.scripts]
34
- sqlnbfmt = "sqlnbfmt.formatter:main"
35
-
36
43
  [tool.pytest.ini_options]
37
44
  markers = [
38
45
  "integration: mark a test as an integration test."
@@ -1,8 +1,10 @@
1
1
  import ast
2
+ import copy
3
+ import difflib
2
4
  import logging
3
5
  import re
4
6
  import sys
5
- from dataclasses import dataclass
7
+ from dataclasses import dataclass, field
6
8
  from pathlib import Path
7
9
  from typing import Set, Optional, Dict, Union, Tuple
8
10
 
@@ -10,6 +12,55 @@ import nbformat
10
12
  import yaml
11
13
  from sqlglot import parse_one, errors
12
14
 
15
+ DEFAULT_SQL_KEYWORDS = frozenset(
16
+ {
17
+ "SELECT",
18
+ "FROM",
19
+ "WHERE",
20
+ "INSERT",
21
+ "UPDATE",
22
+ "DELETE",
23
+ "WITH",
24
+ "JOIN",
25
+ "CREATE",
26
+ "DROP",
27
+ "ALTER",
28
+ "TRUNCATE",
29
+ "UNION",
30
+ "EXCEPT",
31
+ "INTERSECT",
32
+ "GROUP BY",
33
+ "ORDER BY",
34
+ "HAVING",
35
+ "LIMIT",
36
+ "OFFSET",
37
+ "DISTINCT",
38
+ }
39
+ )
40
+
41
+ DEFAULT_FUNCTION_NAMES = frozenset(
42
+ {
43
+ "read_sql",
44
+ "read_sql_query",
45
+ "read_sql_table",
46
+ "execute",
47
+ "executescript",
48
+ "fetchall",
49
+ "fetchone",
50
+ "fetchmany",
51
+ "execute_query",
52
+ }
53
+ )
54
+
55
+ DEFAULT_SQL_DECORATORS = frozenset(
56
+ {
57
+ "sql_decorator",
58
+ "query",
59
+ "sql_query",
60
+ "db_query",
61
+ }
62
+ )
63
+
13
64
 
14
65
  @dataclass
15
66
  class SQLReplacement:
@@ -24,9 +75,13 @@ class SQLReplacement:
24
75
  class FormattingConfig:
25
76
  """Configuration for SQL formatting."""
26
77
 
27
- sql_keywords: Set[str]
28
- function_names: Set[str]
29
- sql_decorators: Set[str]
78
+ sql_keywords: Set[str] = field(default_factory=lambda: set(DEFAULT_SQL_KEYWORDS))
79
+ function_names: Set[str] = field(
80
+ default_factory=lambda: set(DEFAULT_FUNCTION_NAMES)
81
+ )
82
+ sql_decorators: Set[str] = field(
83
+ default_factory=lambda: set(DEFAULT_SQL_DECORATORS)
84
+ )
30
85
  single_line_threshold: int = 80
31
86
  indent_width: int = 4
32
87
 
@@ -49,8 +104,11 @@ def setup_logging(level: str = "INFO") -> logging.Logger:
49
104
  return logger
50
105
 
51
106
 
52
- def load_config(config_path: Union[str, Path] = "config.yaml") -> FormattingConfig:
53
- """Loads configuration from a YAML file."""
107
+ def load_config(config_path: Optional[Union[str, Path]] = None) -> FormattingConfig:
108
+ """Loads configuration from a YAML file, or returns defaults if no path given."""
109
+ if config_path is None:
110
+ return FormattingConfig()
111
+
54
112
  config_file = Path(config_path)
55
113
  if not config_file.is_file():
56
114
  raise FileNotFoundError(f"Configuration file '{config_file}' not found.")
@@ -374,13 +432,21 @@ def process_notebook(
374
432
  config: FormattingConfig,
375
433
  dialect: Optional[str],
376
434
  logger: logging.Logger,
435
+ check_only: bool = False,
377
436
  ) -> bool:
378
- """Processes a Jupyter notebook."""
437
+ """Processes a Jupyter notebook.
438
+
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).
444
+ """
379
445
  try:
380
446
  notebook = nbformat.read(notebook_path, as_version=4)
381
447
  changed = False
382
448
 
383
- for cell in notebook.cells:
449
+ for cell_index, cell in enumerate(notebook.cells):
384
450
  if cell.cell_type != "code":
385
451
  continue
386
452
 
@@ -388,6 +454,10 @@ def process_notebook(
388
454
  if not original_code.strip():
389
455
  continue
390
456
 
457
+ if "# sqlnbfmt: skip" in original_code:
458
+ logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
459
+ continue
460
+
391
461
  lines = original_code.split("\n")
392
462
 
393
463
  # Initialize variables to track magic commands
@@ -452,7 +522,9 @@ def process_notebook(
452
522
  changed = True
453
523
 
454
524
  except SQLFormattingError as e:
455
- logger.warning(f"SQL magic formatting skipped: {e}")
525
+ logger.warning(
526
+ f"[Cell {cell_index}] SQL magic formatting skipped: {e}"
527
+ )
456
528
 
457
529
  continue # Move to the next cell after handling magic command
458
530
 
@@ -478,10 +550,12 @@ def process_notebook(
478
550
  cell.source = result
479
551
  changed = True
480
552
  except SyntaxError:
481
- logger.warning(f"Failed to parse cell:\n{original_code}")
553
+ logger.warning(
554
+ f"[Cell {cell_index}] Failed to parse cell:\n{original_code}"
555
+ )
482
556
  continue
483
557
 
484
- if changed:
558
+ if changed and not check_only:
485
559
  nbformat.write(notebook, notebook_path)
486
560
  logger.info(f"Updated notebook: {notebook_path}")
487
561
 
@@ -492,6 +566,112 @@ def process_notebook(
492
566
  raise
493
567
 
494
568
 
569
+ def diff_notebook(
570
+ notebook_path: Union[str, Path],
571
+ config: FormattingConfig,
572
+ dialect: Optional[str],
573
+ 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)
579
+
580
+ # Format the copy in-memory by running the same cell logic
581
+ for cell_index, cell in enumerate(formatted_nb.cells):
582
+ if cell.cell_type != "code":
583
+ continue
584
+ original_code = cell.source
585
+ if not original_code.strip():
586
+ continue
587
+ if "# sqlnbfmt: skip" in original_code:
588
+ continue
589
+
590
+ lines = original_code.split("\n")
591
+ magic_cmd = None
592
+ magic_cmd_index = None
593
+ for idx, line in enumerate(lines):
594
+ stripped = line.strip()
595
+ if not stripped:
596
+ continue
597
+ if stripped.startswith("#"):
598
+ continue
599
+ if stripped.startswith("%%sql") or stripped.startswith("%sql"):
600
+ magic_cmd = stripped.split()[0]
601
+ magic_cmd_index = idx
602
+ break
603
+ else:
604
+ break
605
+
606
+ if magic_cmd:
607
+ is_cell_magic = magic_cmd.startswith("%%sql")
608
+ if is_cell_magic:
609
+ sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
610
+ else:
611
+ sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
612
+ try:
613
+ formatted_sql = format_sql_code(
614
+ sql_code,
615
+ dialect,
616
+ config,
617
+ is_magic_command=True,
618
+ is_cell_magic=is_cell_magic,
619
+ )
620
+ if is_cell_magic:
621
+ pre_magic = "\n".join(lines[:magic_cmd_index])
622
+ if pre_magic:
623
+ new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
624
+ else:
625
+ new_content = f"{magic_cmd}\n{formatted_sql}"
626
+ else:
627
+ pre_magic = "\n".join(lines[:magic_cmd_index])
628
+ if pre_magic:
629
+ new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
630
+ else:
631
+ new_content = f"{magic_cmd} {formatted_sql}"
632
+ cell.source = new_content
633
+ except SQLFormattingError:
634
+ pass
635
+ continue
636
+
637
+ try:
638
+ tree = ast.parse(original_code)
639
+ line_offsets = build_line_offsets(original_code)
640
+ formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
641
+ formatter.visit(tree)
642
+ if formatter.replacements:
643
+ result = original_code
644
+ for rep in sorted(
645
+ formatter.replacements,
646
+ key=lambda r: r.start_offset,
647
+ reverse=True,
648
+ ):
649
+ result = (
650
+ result[: rep.start_offset]
651
+ + rep.new_text
652
+ + result[rep.end_offset :]
653
+ )
654
+ cell.source = result
655
+ except SyntaxError:
656
+ continue
657
+
658
+ # Build unified diff cell-by-cell
659
+ diff_lines = []
660
+ for i, (orig_cell, fmt_cell) in enumerate(
661
+ zip(original_nb.cells, formatted_nb.cells)
662
+ ):
663
+ if orig_cell.source != fmt_cell.source:
664
+ diff_lines.extend(
665
+ difflib.unified_diff(
666
+ orig_cell.source.splitlines(keepends=True),
667
+ fmt_cell.source.splitlines(keepends=True),
668
+ fromfile=f"Cell [{i}] original",
669
+ tofile=f"Cell [{i}] formatted",
670
+ )
671
+ )
672
+ return "".join(diff_lines)
673
+
674
+
495
675
  def main():
496
676
  """Main entry point for the SQL formatter."""
497
677
  import argparse
@@ -501,7 +681,7 @@ def main():
501
681
  "notebooks", nargs="+", type=Path, help="Notebook paths to process"
502
682
  )
503
683
  parser.add_argument(
504
- "--config", type=Path, default="config.yaml", help="Configuration file path"
684
+ "--config", type=Path, default=None, help="Configuration file path (optional)"
505
685
  )
506
686
  parser.add_argument(
507
687
  "--dialect", type=str, help="SQL dialect (e.g., mysql, postgres)"
@@ -512,27 +692,69 @@ def main():
512
692
  default="INFO",
513
693
  help="Logging level",
514
694
  )
695
+ parser.add_argument(
696
+ "--check",
697
+ action="store_true",
698
+ help="Check formatting without modifying files (exit code 1 if changes needed)",
699
+ )
700
+ parser.add_argument(
701
+ "--diff",
702
+ action="store_true",
703
+ help="Print a unified diff of formatting changes",
704
+ )
515
705
 
516
706
  args = parser.parse_args()
517
707
  logger = setup_logging(args.log_level)
518
708
 
519
709
  try:
520
710
  config = load_config(args.config)
711
+ except FileNotFoundError as e:
712
+ logger.error(
713
+ f"{e}\nHint: --config is optional; omit it to use built-in defaults."
714
+ )
715
+ sys.exit(1)
716
+
717
+ try:
521
718
  changed = False
522
719
  changed_files = []
523
720
 
524
721
  for notebook in args.notebooks:
525
- if process_notebook(notebook, config, args.dialect, logger):
526
- changed = True
527
- changed_files.append(notebook)
528
-
529
- # Print summary
530
- if changed:
531
- logger.info("Changes made to the following notebooks:")
532
- for file in changed_files:
533
- logger.info(f" - {file}")
534
- else:
535
- logger.info("No changes needed. All notebooks are properly formatted.")
722
+ if args.diff:
723
+ diff_output = diff_notebook(notebook, config, args.dialect, logger)
724
+ if diff_output:
725
+ print(diff_output, end="")
726
+ changed = True
727
+ changed_files.append(notebook)
728
+
729
+ if args.check:
730
+ if process_notebook(
731
+ notebook, config, args.dialect, logger, check_only=True
732
+ ):
733
+ changed = True
734
+ changed_files.append(notebook)
735
+ elif not args.diff:
736
+ if process_notebook(notebook, config, args.dialect, logger):
737
+ changed = True
738
+ changed_files.append(notebook)
739
+
740
+ if args.check:
741
+ if changed:
742
+ logger.error(f"{len(changed_files)} notebook(s) would be reformatted:")
743
+ for f in changed_files:
744
+ logger.error(f" - {f}")
745
+ sys.exit(1)
746
+ else:
747
+ logger.info("All notebooks are properly formatted.")
748
+ sys.exit(0)
749
+
750
+ # Print summary for normal mode
751
+ if not args.diff:
752
+ if changed:
753
+ logger.info("Changes made to the following notebooks:")
754
+ for file in changed_files:
755
+ logger.info(f" - {file}")
756
+ else:
757
+ logger.info("No changes needed. All notebooks are properly formatted.")
536
758
 
537
759
  sys.exit(0)
538
760
 
@@ -1,9 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.2.0
3
+ Version: 0.3.0
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
+ Project-URL: Homepage, https://github.com/flyersworder/sqlnbfmt
8
+ Project-URL: Repository, https://github.com/flyersworder/sqlnbfmt
9
+ Project-URL: Changelog, https://github.com/flyersworder/sqlnbfmt/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/flyersworder/sqlnbfmt/issues
11
+ Keywords: sql,jupyter,notebook,formatter,sqlglot,pre-commit
7
12
  Classifier: Programming Language :: Python :: 3
8
13
  Classifier: Programming Language :: Python :: 3.12
9
14
  Classifier: Programming Language :: Python :: 3.13
@@ -21,16 +26,20 @@ Dynamic: license-file
21
26
  [![License](https://img.shields.io/pypi/l/sqlnbfmt.svg)](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
22
27
  [![Python Versions](https://img.shields.io/pypi/pyversions/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
23
28
 
29
+ > **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
30
+
24
31
  A SQL formatter designed specifically for Jupyter Notebooks. `sqlnbfmt` automatically formats SQL queries embedded in code cells, including both Python strings and SQL magic cells (`%%sql`), helping you maintain clean and consistent code.
25
32
 
26
33
  ## Features
27
34
 
28
- - 🎯 **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
29
- - 🌳 **AST-Powered**: Uses Abstract Syntax Tree parsing for accurate SQL string identification
30
- - 🔒 **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), and SQL comments
31
- - ⚙️ **Highly Configurable**: Customize formatting through YAML configuration
32
- - 🔄 **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
33
- - 📦 **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
35
+ - **Zero-config**: Works out of the box with sensible defaults — no config file needed
36
+ - **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
37
+ - **AST-Powered**: Uses Abstract Syntax Tree parsing for accurate SQL string identification
38
+ - **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), and SQL comments
39
+ - **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
40
+ - **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
41
+ - **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
42
+ - **Lightweight**: Only three runtime dependencies (sqlglot, nbformat, pyyaml)
34
43
 
35
44
  ## Installation
36
45
 
@@ -47,9 +56,23 @@ Format a single notebook:
47
56
  sqlnbfmt path/to/your_notebook.ipynb
48
57
  ```
49
58
 
50
- Format all notebooks in a directory:
59
+ Check formatting without modifying files (useful in CI):
60
+ ```bash
61
+ sqlnbfmt --check path/to/your_notebook.ipynb
62
+ ```
63
+
64
+ Show a diff of what would change:
51
65
  ```bash
52
- sqlnbfmt path/to/notebooks/
66
+ sqlnbfmt --diff path/to/your_notebook.ipynb
67
+ ```
68
+
69
+ ### Skipping Cells
70
+
71
+ Add a `# sqlnbfmt: skip` comment anywhere in a cell to skip formatting:
72
+
73
+ ```python
74
+ # sqlnbfmt: skip
75
+ query = "select * from my_special_table where id = 1"
53
76
  ```
54
77
 
55
78
  ### Pre-commit Integration
@@ -63,17 +86,15 @@ pip install pre-commit
63
86
  ```yaml
64
87
  repos:
65
88
  - repo: https://github.com/flyersworder/sqlnbfmt
66
- rev: v0.2.0
89
+ rev: v0.3.0
67
90
  hooks:
68
91
  - id: sqlnbfmt
69
- name: sqlnbfmt
70
92
  types: [jupyter]
71
- args: [--config, config.yaml, --dialect, postgres]
72
93
  ```
73
- Please run the following command for help:
74
94
 
75
- ```bash
76
- sqlnbfmt -h
95
+ All arguments are optional. To specify a dialect or custom config:
96
+ ```yaml
97
+ args: [--dialect, postgres, --config, config.yaml]
77
98
  ```
78
99
 
79
100
  3. Install the hook:
@@ -86,8 +107,21 @@ pre-commit install
86
107
  pre-commit run --all-files
87
108
  ```
88
109
 
110
+ ### CI Usage
111
+
112
+ Use `--check` in GitHub Actions to enforce formatting:
113
+
114
+ ```yaml
115
+ - name: Check SQL notebook formatting
116
+ run: |
117
+ pip install sqlnbfmt
118
+ sqlnbfmt --check **/*.ipynb
119
+ ```
120
+
89
121
  ## Configuration
90
122
 
123
+ `sqlnbfmt` works without any configuration file. A `config.yaml` is only needed to override defaults.
124
+
91
125
  Create a `config.yaml` file to customize formatting behavior. [Here](https://github.com/flyersworder/sqlnbfmt/blob/main/config.yaml) is a template.
92
126
 
93
127
  ### Configuration Options
@@ -95,8 +129,8 @@ Create a `config.yaml` file to customize formatting behavior. [Here](https://git
95
129
  | Option | Description | Default |
96
130
  |--------|-------------|---------|
97
131
  | `sql_keywords` | SQL keywords to recognize and format | Common SQL keywords |
98
- | `function_names` | Python functions containing SQL code | `[]` |
99
- | `sql_decorators` | Decorators indicating SQL code | `[]` |
132
+ | `function_names` | Python functions containing SQL code | `read_sql`, `execute`, etc. |
133
+ | `sql_decorators` | Decorators indicating SQL code | `query`, `sql_query`, etc. |
100
134
  | `single_line_threshold` | Maximum length before splitting SQL | 80 |
101
135
  | `indent_width` | Number of spaces for indentation | 4 |
102
136
 
@@ -125,6 +159,19 @@ ORDER BY
125
159
  """)
126
160
  ```
127
161
 
162
+ ## Troubleshooting
163
+
164
+ **SQL not being formatted?**
165
+ - Ensure the string contains at least 2 SQL keywords or a recognizable pattern like `SELECT...FROM`
166
+ - Check that the function name is in the recognized list (use `--config` to add custom ones)
167
+
168
+ **Comments being modified?**
169
+ - Python comments (`#`) are preserved. SQL comments (`--`) inside strings are converted to `/* */` block comments by sqlglot.
170
+
171
+ **Pre-commit hook fails?**
172
+ - Make sure the `rev` matches the installed version
173
+ - Run `pre-commit autoupdate` to get the latest version
174
+
128
175
  ## Contributing
129
176
 
130
177
  We welcome contributions! Here's how to get started:
@@ -146,6 +193,13 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate
146
193
  pytest
147
194
  ```
148
195
 
196
+ 4. Add eval cases: see `tests/eval/generate_fixtures.py` for examples. Run `python tests/eval/generate_fixtures.py` to regenerate fixtures.
197
+
198
+ 5. Install dev pre-commit hooks:
199
+ ```bash
200
+ pre-commit install
201
+ ```
202
+
149
203
  ## License
150
204
 
151
205
  This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
@@ -14,7 +14,6 @@ import pytest
14
14
  from sqlnbfmt.formatter import load_config, process_notebook
15
15
 
16
16
  EVAL_DIR = Path(__file__).resolve().parent / "eval"
17
- CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.yaml"
18
17
  DIALECT = "mysql"
19
18
 
20
19
 
@@ -54,7 +53,7 @@ def test_eval(case_name, logger, tmp_path):
54
53
  shutil.copy2(case_dir / "input.ipynb", work_path)
55
54
 
56
55
  # Load config and run formatter
57
- config = load_config(CONFIG_PATH)
56
+ config = load_config()
58
57
  process_notebook(work_path, config, DIALECT, logger)
59
58
 
60
59
  # Read formatted and expected
@@ -3,7 +3,7 @@ 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
6
+ from sqlnbfmt.formatter import process_notebook, load_config, diff_notebook
7
7
 
8
8
 
9
9
  # Fixture for temporary notebook path
@@ -216,7 +216,7 @@ def test_sql_formatter(temp_nb_path, logger, input_cells, expected_cells):
216
216
  nbformat.write(nb, temp_nb_path)
217
217
 
218
218
  # Load configuration
219
- config = load_config("config.yaml")
219
+ config = load_config()
220
220
  dialect = "mysql" # or your preferred SQL dialect
221
221
 
222
222
  # Run the formatter on the notebook
@@ -235,3 +235,102 @@ def test_sql_formatter(temp_nb_path, logger, input_cells, expected_cells):
235
235
 
236
236
  # Clean up
237
237
  temp_nb_path.unlink()
238
+
239
+
240
+ def test_skip_hint(tmp_path, logger):
241
+ """Cell with # sqlnbfmt: skip should remain untouched."""
242
+ nb = nbf.new_notebook()
243
+ original_source = (
244
+ '# sqlnbfmt: skip\nquery = "select id, name from users where active = 1"'
245
+ )
246
+ nb.cells = [nbf.new_code_cell(original_source)]
247
+ nb_path = tmp_path / "skip_test.ipynb"
248
+ nbformat.write(nb, nb_path)
249
+
250
+ config = load_config()
251
+ changed = process_notebook(nb_path, config, "mysql", logger)
252
+ assert not changed
253
+
254
+ result_nb = nbformat.read(nb_path, as_version=4)
255
+ assert result_nb.cells[0].source == original_source
256
+
257
+
258
+ def test_check_only_unformatted(tmp_path, logger):
259
+ """--check on unformatted notebook returns True but file unchanged."""
260
+ nb = nbf.new_notebook()
261
+ original_source = 'query = "select id, name from users where active = 1"'
262
+ nb.cells = [nbf.new_code_cell(original_source)]
263
+ nb_path = tmp_path / "check_test.ipynb"
264
+ nbformat.write(nb, nb_path)
265
+
266
+ config = load_config()
267
+ changed = process_notebook(nb_path, config, "mysql", logger, check_only=True)
268
+ assert changed
269
+
270
+ # File should be unchanged
271
+ result_nb = nbformat.read(nb_path, as_version=4)
272
+ assert result_nb.cells[0].source == original_source
273
+
274
+
275
+ def test_check_only_formatted(tmp_path, logger):
276
+ """--check on already-formatted notebook returns False."""
277
+ nb = nbf.new_notebook()
278
+ nb.cells = [
279
+ nbf.new_code_cell(
280
+ 'query = """\nSELECT\n id,\n name\nFROM users\nWHERE\n active = 1\n"""'
281
+ )
282
+ ]
283
+ nb_path = tmp_path / "check_formatted.ipynb"
284
+ nbformat.write(nb, nb_path)
285
+
286
+ config = load_config()
287
+ changed = process_notebook(nb_path, config, "mysql", logger, check_only=True)
288
+ assert not changed
289
+
290
+
291
+ def test_diff_notebook_unformatted(tmp_path, logger):
292
+ """diff_notebook returns non-empty diff for unformatted notebook."""
293
+ nb = nbf.new_notebook()
294
+ nb.cells = [
295
+ nbf.new_code_cell('query = "select id, name from users where active = 1"')
296
+ ]
297
+ nb_path = tmp_path / "diff_test.ipynb"
298
+ nbformat.write(nb, nb_path)
299
+
300
+ config = load_config()
301
+ diff_output = diff_notebook(nb_path, config, "mysql", logger)
302
+ assert diff_output # non-empty
303
+ assert "---" in diff_output
304
+ assert "+++" in diff_output
305
+
306
+
307
+ def test_diff_notebook_formatted(tmp_path, logger):
308
+ """diff_notebook returns empty string for already-formatted notebook."""
309
+ nb = nbf.new_notebook()
310
+ nb.cells = [
311
+ nbf.new_code_cell(
312
+ 'query = """\nSELECT\n id,\n name\nFROM users\nWHERE\n active = 1\n"""'
313
+ )
314
+ ]
315
+ nb_path = tmp_path / "diff_formatted.ipynb"
316
+ nbformat.write(nb, nb_path)
317
+
318
+ config = load_config()
319
+ diff_output = diff_notebook(nb_path, config, "mysql", logger)
320
+ assert diff_output == ""
321
+
322
+
323
+ def test_load_config_defaults():
324
+ """load_config() without args returns defaults."""
325
+ config = load_config()
326
+ assert "SELECT" in config.sql_keywords
327
+ assert "read_sql" in config.function_names
328
+ assert config.indent_width == 4
329
+
330
+
331
+ def test_load_config_missing_file():
332
+ """load_config with missing path raises FileNotFoundError."""
333
+ import pytest
334
+
335
+ with pytest.raises(FileNotFoundError):
336
+ load_config("/nonexistent/config.yaml")
File without changes
File without changes
File without changes