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.
- {sqlnbfmt-0.2.0/sqlnbfmt.egg-info → sqlnbfmt-0.3.1}/PKG-INFO +71 -17
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/README.md +65 -16
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/pyproject.toml +11 -4
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt/formatter.py +286 -113
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1/sqlnbfmt.egg-info}/PKG-INFO +71 -17
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/tests/test_eval.py +1 -2
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/tests/test_formatter.py +228 -2
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/LICENSE +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/setup.cfg +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt/__init__.py +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt.egg-info/SOURCES.txt +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt.egg-info/dependency_links.txt +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt.egg-info/entry_points.txt +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt.egg-info/requires.txt +0 -0
- {sqlnbfmt-0.2.0 → sqlnbfmt-0.3.1}/sqlnbfmt.egg-info/top_level.txt +0 -0
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlnbfmt
|
|
3
|
-
Version: 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
|
[](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
|
|
22
27
|
[](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
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
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
|
[](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
|
|
5
5
|
[](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
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
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
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
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] =
|
|
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(
|
|
64
|
-
|
|
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 = " " *
|
|
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
|
|
373
|
-
|
|
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
|
-
"""
|
|
379
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
459
|
+
for cell_index, cell in enumerate(notebook.cells):
|
|
460
|
+
if cell.cell_type != "code":
|
|
461
|
+
continue
|
|
386
462
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
463
|
+
original_code = cell.source
|
|
464
|
+
if not original_code.strip():
|
|
465
|
+
continue
|
|
390
466
|
|
|
391
|
-
|
|
467
|
+
if _has_skip_hint(original_code):
|
|
468
|
+
logger.debug(f"[Cell {cell_index}] Skipping cell (sqlnbfmt: skip)")
|
|
469
|
+
continue
|
|
392
470
|
|
|
393
|
-
|
|
394
|
-
magic_cmd = None
|
|
395
|
-
magic_cmd_index = None
|
|
471
|
+
lines = original_code.split("\n")
|
|
396
472
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
473
|
+
# Initialize variables to track magic commands
|
|
474
|
+
magic_cmd = None
|
|
475
|
+
magic_cmd_index = None
|
|
400
476
|
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
405
|
-
|
|
481
|
+
if not stripped:
|
|
482
|
+
continue # Skip empty lines
|
|
406
483
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
magic_cmd_index = idx
|
|
410
|
-
break # Magic command found
|
|
484
|
+
if stripped.startswith("#"):
|
|
485
|
+
continue # Skip comment lines
|
|
411
486
|
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
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
|
-
#
|
|
420
|
-
|
|
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
|
-
#
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
578
|
+
Args:
|
|
579
|
+
check_only: If True, detect changes but don't write the file.
|
|
458
580
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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.
|
|
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
|
[](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
|
|
22
27
|
[](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
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
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
|
-
|
|
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/
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
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(
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|