sqlnbfmt 0.2.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,9 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.2.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
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.1"
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.")
@@ -58,10 +116,16 @@ def load_config(config_path: Union[str, Path] = "config.yaml") -> FormattingConf
58
116
  try:
59
117
  with open(config_file) as file:
60
118
  config = yaml.safe_load(file)
119
+ if config is None:
120
+ return FormattingConfig()
61
121
  return FormattingConfig(
62
- sql_keywords=set(config.get("sql_keywords", [])),
63
- function_names=set(config.get("function_names", [])),
64
- 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
+ ),
65
129
  single_line_threshold=config.get("formatting_options", {}).get(
66
130
  "single_line_threshold", 80
67
131
  ),
@@ -314,7 +378,7 @@ class SQLStringFormatter(ast.NodeVisitor):
314
378
  """Constructs the replacement string literal text."""
315
379
  prefix = "f" if is_fstring else ""
316
380
  if in_function and "\n" in formatted_sql:
317
- indent = " " * 4
381
+ indent = " " * self.config.indent_width
318
382
  lines = formatted_sql.split("\n")
319
383
  indented_lines = [indent + line if line.strip() else line for line in lines]
320
384
  indented_sql = "\n".join(indented_lines)
@@ -369,119 +433,159 @@ class SQLStringFormatter(ast.NodeVisitor):
369
433
  return ".".join(reversed(parts))
370
434
 
371
435
 
372
- def process_notebook(
373
- notebook_path: Union[str, Path],
436
+ def _has_skip_hint(source: str) -> bool:
437
+ """Check if any comment line contains the sqlnbfmt: skip directive.
438
+
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.
443
+ """
444
+ return any(
445
+ line.strip().startswith("#") and "sqlnbfmt: skip" in line
446
+ for line in source.split("\n")
447
+ )
448
+
449
+
450
+ def _format_cells(
451
+ notebook: nbformat.NotebookNode,
374
452
  config: FormattingConfig,
375
453
  dialect: Optional[str],
376
454
  logger: logging.Logger,
377
455
  ) -> bool:
378
- """Processes a Jupyter notebook."""
379
- try:
380
- notebook = nbformat.read(notebook_path, as_version=4)
381
- changed = False
456
+ """Format cells in-place. Returns True if any cell was modified."""
457
+ changed = False
382
458
 
383
- for cell in notebook.cells:
384
- if cell.cell_type != "code":
385
- continue
459
+ for cell_index, cell in enumerate(notebook.cells):
460
+ if cell.cell_type != "code":
461
+ continue
386
462
 
387
- original_code = cell.source
388
- if not original_code.strip():
389
- continue
463
+ original_code = cell.source
464
+ if not original_code.strip():
465
+ continue
390
466
 
391
- lines = original_code.split("\n")
467
+ if _has_skip_hint(original_code):
468
+ logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
469
+ continue
392
470
 
393
- # Initialize variables to track magic commands
394
- magic_cmd = None
395
- magic_cmd_index = None
471
+ lines = original_code.split("\n")
396
472
 
397
- # Iterate through lines to find the first non-comment magic command
398
- for idx, line in enumerate(lines):
399
- stripped = line.strip()
473
+ # Initialize variables to track magic commands
474
+ magic_cmd = None
475
+ magic_cmd_index = None
400
476
 
401
- if not stripped:
402
- continue # Skip empty lines
477
+ # Iterate through lines to find the first non-comment magic command
478
+ for idx, line in enumerate(lines):
479
+ stripped = line.strip()
403
480
 
404
- if stripped.startswith("#"):
405
- continue # Skip comment lines
481
+ if not stripped:
482
+ continue # Skip empty lines
406
483
 
407
- if stripped.startswith("%%sql") or stripped.startswith("%sql"):
408
- magic_cmd = stripped.split()[0]
409
- magic_cmd_index = idx
410
- break # Magic command found
484
+ if stripped.startswith("#"):
485
+ continue # Skip comment lines
411
486
 
412
- else:
413
- break # Non-magic, non-comment line found
487
+ if stripped.startswith("%%sql") or stripped.startswith("%sql"):
488
+ magic_cmd = stripped.split()[0]
489
+ magic_cmd_index = idx
490
+ break # Magic command found
491
+
492
+ else:
493
+ break # Non-magic, non-comment line found
414
494
 
415
- if magic_cmd:
416
- is_cell_magic = magic_cmd.startswith("%%sql")
495
+ if magic_cmd:
496
+ is_cell_magic = magic_cmd.startswith("%%sql")
417
497
 
498
+ if is_cell_magic:
499
+ # Cell magic: SQL code starts from the next line
500
+ sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
501
+ else:
502
+ # Line magic: SQL code is on the same line after the magic command
503
+ sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
504
+
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
+ )
513
+
514
+ # Reconstruct the cell content
418
515
  if is_cell_magic:
419
- # Cell magic: SQL code starts from the next line
420
- sql_code = "\n".join(lines[magic_cmd_index + 1 :]).strip()
516
+ # Preserve comments before the magic command
517
+ pre_magic = "\n".join(lines[:magic_cmd_index])
518
+ if pre_magic:
519
+ new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
520
+ else:
521
+ new_content = f"{magic_cmd}\n{formatted_sql}"
421
522
  else:
422
- # Line magic: SQL code is on the same line after the magic command
423
- sql_code = lines[magic_cmd_index][len(magic_cmd) :].strip()
424
-
425
- try:
426
- formatted_sql = format_sql_code(
427
- sql_code,
428
- dialect,
429
- config,
430
- is_magic_command=True,
431
- is_cell_magic=is_cell_magic,
523
+ # Preserve comments before the magic command
524
+ pre_magic = "\n".join(lines[:magic_cmd_index])
525
+ if pre_magic:
526
+ new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
527
+ else:
528
+ new_content = f"{magic_cmd} {formatted_sql}"
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
540
+ try:
541
+ tree = ast.parse(original_code)
542
+ line_offsets = build_line_offsets(original_code)
543
+ formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
544
+ formatter.visit(tree)
545
+ if formatter.replacements:
546
+ result = original_code
547
+ for rep in sorted(
548
+ formatter.replacements,
549
+ key=lambda r: r.start_offset,
550
+ reverse=True,
551
+ ):
552
+ result = (
553
+ result[: rep.start_offset]
554
+ + rep.new_text
555
+ + result[rep.end_offset :]
432
556
  )
557
+ if result != original_code:
558
+ cell.source = result
559
+ changed = True
560
+ except SyntaxError:
561
+ logger.warning(
562
+ f"[Cell {cell_index}] Failed to parse cell:\n{original_code}"
563
+ )
564
+ continue
433
565
 
434
- # Reconstruct the cell content
435
- if is_cell_magic:
436
- # Preserve comments before the magic command
437
- pre_magic = "\n".join(lines[:magic_cmd_index])
438
- if pre_magic:
439
- new_content = f"{pre_magic}\n{magic_cmd}\n{formatted_sql}"
440
- else:
441
- new_content = f"{magic_cmd}\n{formatted_sql}"
442
- else:
443
- # Preserve comments before the magic command
444
- pre_magic = "\n".join(lines[:magic_cmd_index])
445
- if pre_magic:
446
- new_content = f"{pre_magic}\n{magic_cmd} {formatted_sql}"
447
- else:
448
- new_content = f"{magic_cmd} {formatted_sql}"
566
+ return changed
449
567
 
450
- if new_content != original_code:
451
- cell.source = new_content
452
- changed = True
453
568
 
454
- except SQLFormattingError as e:
455
- logger.warning(f"SQL magic formatting skipped: {e}")
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.
456
577
 
457
- continue # Move to the next cell after handling magic command
578
+ Args:
579
+ check_only: If True, detect changes but don't write the file.
458
580
 
459
- # Handle regular Python code with surgical string replacement
460
- try:
461
- tree = ast.parse(original_code)
462
- line_offsets = build_line_offsets(original_code)
463
- formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
464
- formatter.visit(tree)
465
- if formatter.replacements:
466
- result = original_code
467
- for rep in sorted(
468
- formatter.replacements,
469
- key=lambda r: r.start_offset,
470
- reverse=True,
471
- ):
472
- result = (
473
- result[: rep.start_offset]
474
- + rep.new_text
475
- + result[rep.end_offset :]
476
- )
477
- if result != original_code:
478
- cell.source = result
479
- changed = True
480
- except SyntaxError:
481
- logger.warning(f"Failed to parse cell:\n{original_code}")
482
- continue
483
-
484
- if changed:
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:
485
589
  nbformat.write(notebook, notebook_path)
486
590
  logger.info(f"Updated notebook: {notebook_path}")
487
591
 
@@ -492,6 +596,36 @@ def process_notebook(
492
596
  raise
493
597
 
494
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
+
612
+ # Build unified diff cell-by-cell
613
+ diff_lines = []
614
+ for i, (orig_cell, fmt_cell) in enumerate(
615
+ zip(original_nb.cells, formatted_nb.cells)
616
+ ):
617
+ if orig_cell.source != fmt_cell.source:
618
+ diff_lines.extend(
619
+ difflib.unified_diff(
620
+ orig_cell.source.splitlines(keepends=True),
621
+ fmt_cell.source.splitlines(keepends=True),
622
+ fromfile=f"{notebook_path} Cell [{i}] original",
623
+ tofile=f"{notebook_path} Cell [{i}] formatted",
624
+ )
625
+ )
626
+ return "".join(diff_lines)
627
+
628
+
495
629
  def main():
496
630
  """Main entry point for the SQL formatter."""
497
631
  import argparse
@@ -501,7 +635,7 @@ def main():
501
635
  "notebooks", nargs="+", type=Path, help="Notebook paths to process"
502
636
  )
503
637
  parser.add_argument(
504
- "--config", type=Path, default="config.yaml", help="Configuration file path"
638
+ "--config", type=Path, default=None, help="Configuration file path (optional)"
505
639
  )
506
640
  parser.add_argument(
507
641
  "--dialect", type=str, help="SQL dialect (e.g., mysql, postgres)"
@@ -512,27 +646,66 @@ def main():
512
646
  default="INFO",
513
647
  help="Logging level",
514
648
  )
649
+ parser.add_argument(
650
+ "--check",
651
+ action="store_true",
652
+ help="Check formatting without modifying files (exit code 1 if changes needed)",
653
+ )
654
+ parser.add_argument(
655
+ "--diff",
656
+ action="store_true",
657
+ help="Print a unified diff of formatting changes",
658
+ )
515
659
 
516
660
  args = parser.parse_args()
517
661
  logger = setup_logging(args.log_level)
518
662
 
519
663
  try:
520
664
  config = load_config(args.config)
521
- changed = False
665
+ except FileNotFoundError as e:
666
+ logger.error(
667
+ f"{e}\nHint: --config is optional; omit it to use built-in defaults."
668
+ )
669
+ sys.exit(1)
670
+
671
+ try:
522
672
  changed_files = []
523
673
 
524
674
  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.")
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).
678
+ diff_output = diff_notebook(notebook, config, args.dialect, logger)
679
+ needs_formatting = bool(diff_output)
680
+
681
+ if args.diff and diff_output:
682
+ print(diff_output, end="")
683
+
684
+ if needs_formatting:
685
+ changed_files.append(notebook)
686
+ else:
687
+ # Normal mode: format in place
688
+ if process_notebook(notebook, config, args.dialect, logger):
689
+ changed_files.append(notebook)
690
+
691
+ if args.check:
692
+ if changed_files:
693
+ logger.error(f"{len(changed_files)} notebook(s) would be reformatted:")
694
+ for f in changed_files:
695
+ logger.error(f" - {f}")
696
+ sys.exit(1)
697
+ else:
698
+ logger.info("All notebooks are properly formatted.")
699
+ sys.exit(0)
700
+
701
+ # Print summary for normal mode
702
+ if not args.diff:
703
+ if changed_files:
704
+ logger.info("Changes made to the following notebooks:")
705
+ for file in changed_files:
706
+ logger.info(f" - {file}")
707
+ else:
708
+ logger.info("No changes needed. All notebooks are properly formatted.")
536
709
 
537
710
  sys.exit(0)
538
711
 
@@ -1,9 +1,14 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.2.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
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,229 @@ 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")
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