sqlnbfmt 0.3.1__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/PKG-INFO +68 -11
- sqlnbfmt-0.3.1/sqlnbfmt.egg-info/PKG-INFO → sqlnbfmt-0.4.0/README.md +58 -30
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/pyproject.toml +11 -4
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt/formatter.py +137 -20
- sqlnbfmt-0.3.1/README.md → sqlnbfmt-0.4.0/sqlnbfmt.egg-info/PKG-INFO +87 -8
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt.egg-info/SOURCES.txt +2 -1
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/tests/test_eval.py +35 -2
- sqlnbfmt-0.4.0/tests/test_marimo.py +278 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/LICENSE +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/setup.cfg +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt/__init__.py +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt.egg-info/dependency_links.txt +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt.egg-info/entry_points.txt +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt.egg-info/requires.txt +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/sqlnbfmt.egg-info/top_level.txt +0 -0
- {sqlnbfmt-0.3.1 → sqlnbfmt-0.4.0}/tests/test_formatter.py +0 -0
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sqlnbfmt
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: A tool to format SQL code in Jupyter notebooks.
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: A tool to format SQL code in Jupyter notebooks and Marimo notebooks.
|
|
5
5
|
Author-email: Qing <qingye779@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/flyersworder/sqlnbfmt
|
|
8
8
|
Project-URL: Repository, https://github.com/flyersworder/sqlnbfmt
|
|
9
9
|
Project-URL: Changelog, https://github.com/flyersworder/sqlnbfmt/blob/main/CHANGELOG.md
|
|
10
10
|
Project-URL: Issues, https://github.com/flyersworder/sqlnbfmt/issues
|
|
11
|
-
Keywords: sql,jupyter,notebook,formatter,sqlglot,pre-commit
|
|
11
|
+
Keywords: sql,jupyter,marimo,notebook,formatter,sqlglot,pre-commit
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
16
|
Classifier: Programming Language :: Python :: 3
|
|
13
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Framework :: Jupyter
|
|
15
22
|
Requires-Python: >=3.12
|
|
16
23
|
Description-Content-Type: text/markdown
|
|
17
24
|
License-File: LICENSE
|
|
@@ -23,19 +30,21 @@ Dynamic: license-file
|
|
|
23
30
|
# sqlnbfmt
|
|
24
31
|
|
|
25
32
|
[](https://pypi.org/project/sqlnbfmt/)
|
|
33
|
+
[](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml)
|
|
26
34
|
[](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
|
|
27
35
|
[](https://pypi.org/project/sqlnbfmt/)
|
|
28
36
|
|
|
29
37
|
> **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
|
|
30
38
|
|
|
31
|
-
A SQL formatter
|
|
39
|
+
A SQL formatter for Jupyter Notebooks and Marimo notebooks. `sqlnbfmt` automatically formats SQL queries embedded in notebook cells — Python strings, SQL magic cells (`%%sql`), and Marimo `mo.sql()` calls — helping you maintain clean and consistent code.
|
|
32
40
|
|
|
33
41
|
## Features
|
|
34
42
|
|
|
35
43
|
- **Zero-config**: Works out of the box with sensible defaults — no config file needed
|
|
36
|
-
- **
|
|
44
|
+
- **Jupyter + Marimo**: Formats SQL in `.ipynb` notebooks and Marimo `.py` notebooks
|
|
45
|
+
- **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells, magic SQL cells, and `mo.sql()` calls
|
|
37
46
|
- **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
|
|
47
|
+
- **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), f-string placeholders, and SQL comments
|
|
39
48
|
- **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
|
|
40
49
|
- **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
|
|
41
50
|
- **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
|
|
@@ -51,14 +60,24 @@ pip install sqlnbfmt
|
|
|
51
60
|
|
|
52
61
|
### Command Line
|
|
53
62
|
|
|
54
|
-
Format
|
|
63
|
+
Format Jupyter notebooks:
|
|
55
64
|
```bash
|
|
56
65
|
sqlnbfmt path/to/your_notebook.ipynb
|
|
57
66
|
```
|
|
58
67
|
|
|
68
|
+
Format Marimo notebooks (or any Python file with SQL strings):
|
|
69
|
+
```bash
|
|
70
|
+
sqlnbfmt path/to/your_notebook.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Mix both in a single invocation:
|
|
74
|
+
```bash
|
|
75
|
+
sqlnbfmt *.ipynb marimo_app.py
|
|
76
|
+
```
|
|
77
|
+
|
|
59
78
|
Check formatting without modifying files (useful in CI):
|
|
60
79
|
```bash
|
|
61
|
-
sqlnbfmt --check path/to/your_notebook.ipynb
|
|
80
|
+
sqlnbfmt --check path/to/your_notebook.ipynb path/to/marimo_app.py
|
|
62
81
|
```
|
|
63
82
|
|
|
64
83
|
Show a diff of what would change:
|
|
@@ -68,7 +87,7 @@ sqlnbfmt --diff path/to/your_notebook.ipynb
|
|
|
68
87
|
|
|
69
88
|
### Skipping Cells
|
|
70
89
|
|
|
71
|
-
Add a `# sqlnbfmt: skip` comment
|
|
90
|
+
Add a `# sqlnbfmt: skip` comment to skip formatting. In Jupyter notebooks this applies per-cell; in Python files it applies to the entire file.
|
|
72
91
|
|
|
73
92
|
```python
|
|
74
93
|
# sqlnbfmt: skip
|
|
@@ -86,12 +105,18 @@ pip install pre-commit
|
|
|
86
105
|
```yaml
|
|
87
106
|
repos:
|
|
88
107
|
- repo: https://github.com/flyersworder/sqlnbfmt
|
|
89
|
-
rev: v0.
|
|
108
|
+
rev: v0.4.0
|
|
90
109
|
hooks:
|
|
91
110
|
- id: sqlnbfmt
|
|
92
111
|
types: [jupyter]
|
|
93
112
|
```
|
|
94
113
|
|
|
114
|
+
To also format Marimo notebooks (Python files), add the Python hook:
|
|
115
|
+
```yaml
|
|
116
|
+
- id: sqlnbfmt-py
|
|
117
|
+
files: '\.py$' # optionally narrow with: files: 'marimo_.*\.py$'
|
|
118
|
+
```
|
|
119
|
+
|
|
95
120
|
All arguments are optional. To specify a dialect or custom config:
|
|
96
121
|
```yaml
|
|
97
122
|
args: [--dialect, postgres, --config, config.yaml]
|
|
@@ -116,6 +141,7 @@ Use `--check` in GitHub Actions to enforce formatting:
|
|
|
116
141
|
run: |
|
|
117
142
|
pip install sqlnbfmt
|
|
118
143
|
sqlnbfmt --check **/*.ipynb
|
|
144
|
+
sqlnbfmt --check marimo_*.py # if using Marimo
|
|
119
145
|
```
|
|
120
146
|
|
|
121
147
|
## Configuration
|
|
@@ -134,7 +160,9 @@ Create a `config.yaml` file to customize formatting behavior. [Here](https://git
|
|
|
134
160
|
| `single_line_threshold` | Maximum length before splitting SQL | 80 |
|
|
135
161
|
| `indent_width` | Number of spaces for indentation | 4 |
|
|
136
162
|
|
|
137
|
-
##
|
|
163
|
+
## Examples
|
|
164
|
+
|
|
165
|
+
### Jupyter Notebook
|
|
138
166
|
|
|
139
167
|
Before formatting:
|
|
140
168
|
```python
|
|
@@ -159,6 +187,35 @@ ORDER BY
|
|
|
159
187
|
""")
|
|
160
188
|
```
|
|
161
189
|
|
|
190
|
+
### Marimo Notebook
|
|
191
|
+
|
|
192
|
+
Before formatting:
|
|
193
|
+
```python
|
|
194
|
+
@app.cell
|
|
195
|
+
def _(mo):
|
|
196
|
+
_df = mo.sql(f"select id, name from users where active = 1 order by name")
|
|
197
|
+
return
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
After formatting:
|
|
201
|
+
```python
|
|
202
|
+
@app.cell
|
|
203
|
+
def _(mo):
|
|
204
|
+
_df = mo.sql(
|
|
205
|
+
f"""
|
|
206
|
+
SELECT
|
|
207
|
+
id,
|
|
208
|
+
name
|
|
209
|
+
FROM users
|
|
210
|
+
WHERE
|
|
211
|
+
active = 1
|
|
212
|
+
ORDER BY
|
|
213
|
+
name
|
|
214
|
+
"""
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
```
|
|
218
|
+
|
|
162
219
|
## Troubleshooting
|
|
163
220
|
|
|
164
221
|
**SQL not being formatted?**
|
|
@@ -1,41 +1,21 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: sqlnbfmt
|
|
3
|
-
Version: 0.3.1
|
|
4
|
-
Summary: A tool to format SQL code in Jupyter notebooks.
|
|
5
|
-
Author-email: Qing <qingye779@gmail.com>
|
|
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
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
-
Requires-Python: >=3.12
|
|
16
|
-
Description-Content-Type: text/markdown
|
|
17
|
-
License-File: LICENSE
|
|
18
|
-
Requires-Dist: nbformat>=5.10.4
|
|
19
|
-
Requires-Dist: pyyaml>=6.0.2
|
|
20
|
-
Requires-Dist: sqlglot>=25.27.0
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
|
|
23
1
|
# sqlnbfmt
|
|
24
2
|
|
|
25
3
|
[](https://pypi.org/project/sqlnbfmt/)
|
|
4
|
+
[](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml)
|
|
26
5
|
[](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
|
|
27
6
|
[](https://pypi.org/project/sqlnbfmt/)
|
|
28
7
|
|
|
29
8
|
> **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
|
|
30
9
|
|
|
31
|
-
A SQL formatter
|
|
10
|
+
A SQL formatter for Jupyter Notebooks and Marimo notebooks. `sqlnbfmt` automatically formats SQL queries embedded in notebook cells — Python strings, SQL magic cells (`%%sql`), and Marimo `mo.sql()` calls — helping you maintain clean and consistent code.
|
|
32
11
|
|
|
33
12
|
## Features
|
|
34
13
|
|
|
35
14
|
- **Zero-config**: Works out of the box with sensible defaults — no config file needed
|
|
36
|
-
- **
|
|
15
|
+
- **Jupyter + Marimo**: Formats SQL in `.ipynb` notebooks and Marimo `.py` notebooks
|
|
16
|
+
- **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells, magic SQL cells, and `mo.sql()` calls
|
|
37
17
|
- **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
|
|
18
|
+
- **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), f-string placeholders, and SQL comments
|
|
39
19
|
- **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
|
|
40
20
|
- **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
|
|
41
21
|
- **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
|
|
@@ -51,14 +31,24 @@ pip install sqlnbfmt
|
|
|
51
31
|
|
|
52
32
|
### Command Line
|
|
53
33
|
|
|
54
|
-
Format
|
|
34
|
+
Format Jupyter notebooks:
|
|
55
35
|
```bash
|
|
56
36
|
sqlnbfmt path/to/your_notebook.ipynb
|
|
57
37
|
```
|
|
58
38
|
|
|
39
|
+
Format Marimo notebooks (or any Python file with SQL strings):
|
|
40
|
+
```bash
|
|
41
|
+
sqlnbfmt path/to/your_notebook.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Mix both in a single invocation:
|
|
45
|
+
```bash
|
|
46
|
+
sqlnbfmt *.ipynb marimo_app.py
|
|
47
|
+
```
|
|
48
|
+
|
|
59
49
|
Check formatting without modifying files (useful in CI):
|
|
60
50
|
```bash
|
|
61
|
-
sqlnbfmt --check path/to/your_notebook.ipynb
|
|
51
|
+
sqlnbfmt --check path/to/your_notebook.ipynb path/to/marimo_app.py
|
|
62
52
|
```
|
|
63
53
|
|
|
64
54
|
Show a diff of what would change:
|
|
@@ -68,7 +58,7 @@ sqlnbfmt --diff path/to/your_notebook.ipynb
|
|
|
68
58
|
|
|
69
59
|
### Skipping Cells
|
|
70
60
|
|
|
71
|
-
Add a `# sqlnbfmt: skip` comment
|
|
61
|
+
Add a `# sqlnbfmt: skip` comment to skip formatting. In Jupyter notebooks this applies per-cell; in Python files it applies to the entire file.
|
|
72
62
|
|
|
73
63
|
```python
|
|
74
64
|
# sqlnbfmt: skip
|
|
@@ -86,12 +76,18 @@ pip install pre-commit
|
|
|
86
76
|
```yaml
|
|
87
77
|
repos:
|
|
88
78
|
- repo: https://github.com/flyersworder/sqlnbfmt
|
|
89
|
-
rev: v0.
|
|
79
|
+
rev: v0.4.0
|
|
90
80
|
hooks:
|
|
91
81
|
- id: sqlnbfmt
|
|
92
82
|
types: [jupyter]
|
|
93
83
|
```
|
|
94
84
|
|
|
85
|
+
To also format Marimo notebooks (Python files), add the Python hook:
|
|
86
|
+
```yaml
|
|
87
|
+
- id: sqlnbfmt-py
|
|
88
|
+
files: '\.py$' # optionally narrow with: files: 'marimo_.*\.py$'
|
|
89
|
+
```
|
|
90
|
+
|
|
95
91
|
All arguments are optional. To specify a dialect or custom config:
|
|
96
92
|
```yaml
|
|
97
93
|
args: [--dialect, postgres, --config, config.yaml]
|
|
@@ -116,6 +112,7 @@ Use `--check` in GitHub Actions to enforce formatting:
|
|
|
116
112
|
run: |
|
|
117
113
|
pip install sqlnbfmt
|
|
118
114
|
sqlnbfmt --check **/*.ipynb
|
|
115
|
+
sqlnbfmt --check marimo_*.py # if using Marimo
|
|
119
116
|
```
|
|
120
117
|
|
|
121
118
|
## Configuration
|
|
@@ -134,7 +131,9 @@ Create a `config.yaml` file to customize formatting behavior. [Here](https://git
|
|
|
134
131
|
| `single_line_threshold` | Maximum length before splitting SQL | 80 |
|
|
135
132
|
| `indent_width` | Number of spaces for indentation | 4 |
|
|
136
133
|
|
|
137
|
-
##
|
|
134
|
+
## Examples
|
|
135
|
+
|
|
136
|
+
### Jupyter Notebook
|
|
138
137
|
|
|
139
138
|
Before formatting:
|
|
140
139
|
```python
|
|
@@ -159,6 +158,35 @@ ORDER BY
|
|
|
159
158
|
""")
|
|
160
159
|
```
|
|
161
160
|
|
|
161
|
+
### Marimo Notebook
|
|
162
|
+
|
|
163
|
+
Before formatting:
|
|
164
|
+
```python
|
|
165
|
+
@app.cell
|
|
166
|
+
def _(mo):
|
|
167
|
+
_df = mo.sql(f"select id, name from users where active = 1 order by name")
|
|
168
|
+
return
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
After formatting:
|
|
172
|
+
```python
|
|
173
|
+
@app.cell
|
|
174
|
+
def _(mo):
|
|
175
|
+
_df = mo.sql(
|
|
176
|
+
f"""
|
|
177
|
+
SELECT
|
|
178
|
+
id,
|
|
179
|
+
name
|
|
180
|
+
FROM users
|
|
181
|
+
WHERE
|
|
182
|
+
active = 1
|
|
183
|
+
ORDER BY
|
|
184
|
+
name
|
|
185
|
+
"""
|
|
186
|
+
)
|
|
187
|
+
return
|
|
188
|
+
```
|
|
189
|
+
|
|
162
190
|
## Troubleshooting
|
|
163
191
|
|
|
164
192
|
**SQL not being formatted?**
|
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sqlnbfmt"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "A tool to format SQL code in Jupyter notebooks."
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "A tool to format SQL code in Jupyter notebooks and Marimo notebooks."
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Qing", email = "qingye779@gmail.com" }
|
|
7
7
|
]
|
|
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
|
+
keywords = ["sql", "jupyter", "marimo", "notebook", "formatter", "sqlglot", "pre-commit"]
|
|
12
12
|
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Intended Audience :: Science/Research",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
13
17
|
"Programming Language :: Python :: 3",
|
|
14
18
|
"Programming Language :: Python :: 3.12",
|
|
15
|
-
"Programming Language :: Python :: 3.13"
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
"Framework :: Jupyter",
|
|
16
23
|
]
|
|
17
24
|
dependencies = [
|
|
18
25
|
"nbformat>=5.10.4",
|
|
@@ -4,13 +4,14 @@ import difflib
|
|
|
4
4
|
import logging
|
|
5
5
|
import re
|
|
6
6
|
import sys
|
|
7
|
+
import textwrap
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Set, Optional, Dict, Union, Tuple
|
|
10
11
|
|
|
11
12
|
import nbformat
|
|
12
13
|
import yaml
|
|
13
|
-
from sqlglot import
|
|
14
|
+
from sqlglot import parse, errors
|
|
14
15
|
|
|
15
16
|
DEFAULT_SQL_KEYWORDS = frozenset(
|
|
16
17
|
{
|
|
@@ -49,6 +50,7 @@ DEFAULT_FUNCTION_NAMES = frozenset(
|
|
|
49
50
|
"fetchone",
|
|
50
51
|
"fetchmany",
|
|
51
52
|
"execute_query",
|
|
53
|
+
"sql",
|
|
52
54
|
}
|
|
53
55
|
)
|
|
54
56
|
|
|
@@ -206,10 +208,18 @@ def format_sql_code(
|
|
|
206
208
|
temp_sql = temp_sql.strip()
|
|
207
209
|
|
|
208
210
|
# Parse and format SQL
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
211
|
+
statements = parse(temp_sql, read=dialect)
|
|
212
|
+
formatted_parts = []
|
|
213
|
+
for stmt in statements:
|
|
214
|
+
formatted_parts.append(
|
|
215
|
+
stmt.sql(
|
|
216
|
+
pretty=not force_single_line,
|
|
217
|
+
indent=config.indent_width,
|
|
218
|
+
dialect=dialect,
|
|
219
|
+
)
|
|
220
|
+
)
|
|
221
|
+
separator = "; " if force_single_line else ";\n"
|
|
222
|
+
formatted_sql = separator.join(formatted_parts)
|
|
213
223
|
|
|
214
224
|
# Apply formatting based on context
|
|
215
225
|
if is_magic_command and not is_cell_magic:
|
|
@@ -351,7 +361,7 @@ class SQLStringFormatter(ast.NodeVisitor):
|
|
|
351
361
|
except SQLFormattingError:
|
|
352
362
|
return None
|
|
353
363
|
|
|
354
|
-
if formatted_sql == sql_str:
|
|
364
|
+
if formatted_sql == textwrap.dedent(sql_str).strip():
|
|
355
365
|
return None
|
|
356
366
|
|
|
357
367
|
# Restore placeholders as f-string expressions
|
|
@@ -566,6 +576,47 @@ def _format_cells(
|
|
|
566
576
|
return changed
|
|
567
577
|
|
|
568
578
|
|
|
579
|
+
def _format_python_source(
|
|
580
|
+
source: str,
|
|
581
|
+
config: FormattingConfig,
|
|
582
|
+
dialect: Optional[str],
|
|
583
|
+
logger: logging.Logger,
|
|
584
|
+
) -> Tuple[str, bool]:
|
|
585
|
+
"""Format SQL strings in Python source code.
|
|
586
|
+
|
|
587
|
+
Returns (formatted_source, changed) tuple.
|
|
588
|
+
"""
|
|
589
|
+
if not source.strip():
|
|
590
|
+
return source, False
|
|
591
|
+
|
|
592
|
+
if _has_skip_hint(source):
|
|
593
|
+
return source, False
|
|
594
|
+
|
|
595
|
+
try:
|
|
596
|
+
tree = ast.parse(source)
|
|
597
|
+
except SyntaxError:
|
|
598
|
+
logger.warning("Failed to parse Python source")
|
|
599
|
+
return source, False
|
|
600
|
+
|
|
601
|
+
line_offsets = build_line_offsets(source)
|
|
602
|
+
formatter = SQLStringFormatter(config, dialect, logger, line_offsets)
|
|
603
|
+
formatter.visit(tree)
|
|
604
|
+
|
|
605
|
+
if not formatter.replacements:
|
|
606
|
+
return source, False
|
|
607
|
+
|
|
608
|
+
result = source
|
|
609
|
+
for rep in sorted(
|
|
610
|
+
formatter.replacements,
|
|
611
|
+
key=lambda r: r.start_offset,
|
|
612
|
+
reverse=True,
|
|
613
|
+
):
|
|
614
|
+
result = result[: rep.start_offset] + rep.new_text + result[rep.end_offset :]
|
|
615
|
+
|
|
616
|
+
changed = result != source
|
|
617
|
+
return result, changed
|
|
618
|
+
|
|
619
|
+
|
|
569
620
|
def process_notebook(
|
|
570
621
|
notebook_path: Union[str, Path],
|
|
571
622
|
config: FormattingConfig,
|
|
@@ -626,13 +677,69 @@ def diff_notebook(
|
|
|
626
677
|
return "".join(diff_lines)
|
|
627
678
|
|
|
628
679
|
|
|
680
|
+
def process_python_file(
|
|
681
|
+
file_path: Union[str, Path],
|
|
682
|
+
config: FormattingConfig,
|
|
683
|
+
dialect: Optional[str],
|
|
684
|
+
logger: logging.Logger,
|
|
685
|
+
check_only: bool = False,
|
|
686
|
+
) -> bool:
|
|
687
|
+
"""Processes a Python file (e.g. Marimo notebook).
|
|
688
|
+
|
|
689
|
+
Returns True if changes were made (or would be made in check mode).
|
|
690
|
+
"""
|
|
691
|
+
try:
|
|
692
|
+
file_path = Path(file_path)
|
|
693
|
+
source = file_path.read_text(encoding="utf-8")
|
|
694
|
+
formatted, changed = _format_python_source(source, config, dialect, logger)
|
|
695
|
+
|
|
696
|
+
if changed and not check_only:
|
|
697
|
+
file_path.write_text(formatted, encoding="utf-8")
|
|
698
|
+
logger.info(f"Updated file: {file_path}")
|
|
699
|
+
|
|
700
|
+
return changed
|
|
701
|
+
|
|
702
|
+
except Exception as e:
|
|
703
|
+
logger.error(f"Failed to process file {file_path}: {e}", exc_info=True)
|
|
704
|
+
raise
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def diff_python_file(
|
|
708
|
+
file_path: Union[str, Path],
|
|
709
|
+
config: FormattingConfig,
|
|
710
|
+
dialect: Optional[str],
|
|
711
|
+
logger: logging.Logger,
|
|
712
|
+
) -> str:
|
|
713
|
+
"""Returns a unified diff of formatting changes for a Python file."""
|
|
714
|
+
file_path = Path(file_path)
|
|
715
|
+
source = file_path.read_text(encoding="utf-8")
|
|
716
|
+
formatted, _ = _format_python_source(source, config, dialect, logger)
|
|
717
|
+
|
|
718
|
+
if source == formatted:
|
|
719
|
+
return ""
|
|
720
|
+
|
|
721
|
+
return "".join(
|
|
722
|
+
difflib.unified_diff(
|
|
723
|
+
source.splitlines(keepends=True),
|
|
724
|
+
formatted.splitlines(keepends=True),
|
|
725
|
+
fromfile=f"{file_path} original",
|
|
726
|
+
tofile=f"{file_path} formatted",
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
|
|
629
731
|
def main():
|
|
630
732
|
"""Main entry point for the SQL formatter."""
|
|
631
733
|
import argparse
|
|
632
734
|
|
|
633
|
-
parser = argparse.ArgumentParser(
|
|
735
|
+
parser = argparse.ArgumentParser(
|
|
736
|
+
description="Format SQL code in Jupyter notebooks and Python files"
|
|
737
|
+
)
|
|
634
738
|
parser.add_argument(
|
|
635
|
-
"
|
|
739
|
+
"files",
|
|
740
|
+
nargs="+",
|
|
741
|
+
type=Path,
|
|
742
|
+
help="Notebook (.ipynb) or Python (.py) file paths to process",
|
|
636
743
|
)
|
|
637
744
|
parser.add_argument(
|
|
638
745
|
"--config", type=Path, default=None, help="Configuration file path (optional)"
|
|
@@ -671,41 +778,51 @@ def main():
|
|
|
671
778
|
try:
|
|
672
779
|
changed_files = []
|
|
673
780
|
|
|
674
|
-
for
|
|
781
|
+
for file_path in args.files:
|
|
782
|
+
is_python = file_path.suffix == ".py"
|
|
783
|
+
|
|
675
784
|
if args.check or args.diff:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
785
|
+
if is_python:
|
|
786
|
+
diff_output = diff_python_file(
|
|
787
|
+
file_path, config, args.dialect, logger
|
|
788
|
+
)
|
|
789
|
+
else:
|
|
790
|
+
diff_output = diff_notebook(file_path, config, args.dialect, logger)
|
|
679
791
|
needs_formatting = bool(diff_output)
|
|
680
792
|
|
|
681
793
|
if args.diff and diff_output:
|
|
682
794
|
print(diff_output, end="")
|
|
683
795
|
|
|
684
796
|
if needs_formatting:
|
|
685
|
-
changed_files.append(
|
|
797
|
+
changed_files.append(file_path)
|
|
686
798
|
else:
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
799
|
+
if is_python:
|
|
800
|
+
changed = process_python_file(
|
|
801
|
+
file_path, config, args.dialect, logger
|
|
802
|
+
)
|
|
803
|
+
else:
|
|
804
|
+
changed = process_notebook(file_path, config, args.dialect, logger)
|
|
805
|
+
if changed:
|
|
806
|
+
changed_files.append(file_path)
|
|
690
807
|
|
|
691
808
|
if args.check:
|
|
692
809
|
if changed_files:
|
|
693
|
-
logger.error(f"{len(changed_files)}
|
|
810
|
+
logger.error(f"{len(changed_files)} file(s) would be reformatted:")
|
|
694
811
|
for f in changed_files:
|
|
695
812
|
logger.error(f" - {f}")
|
|
696
813
|
sys.exit(1)
|
|
697
814
|
else:
|
|
698
|
-
logger.info("All
|
|
815
|
+
logger.info("All files are properly formatted.")
|
|
699
816
|
sys.exit(0)
|
|
700
817
|
|
|
701
818
|
# Print summary for normal mode
|
|
702
819
|
if not args.diff:
|
|
703
820
|
if changed_files:
|
|
704
|
-
logger.info("Changes made to the following
|
|
821
|
+
logger.info("Changes made to the following files:")
|
|
705
822
|
for file in changed_files:
|
|
706
823
|
logger.info(f" - {file}")
|
|
707
824
|
else:
|
|
708
|
-
logger.info("No changes needed. All
|
|
825
|
+
logger.info("No changes needed. All files are properly formatted.")
|
|
709
826
|
|
|
710
827
|
sys.exit(0)
|
|
711
828
|
|
|
@@ -1,19 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlnbfmt
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: A tool to format SQL code in Jupyter notebooks and Marimo notebooks.
|
|
5
|
+
Author-email: Qing <qingye779@gmail.com>
|
|
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,marimo,notebook,formatter,sqlglot,pre-commit
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Framework :: Jupyter
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: nbformat>=5.10.4
|
|
26
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
27
|
+
Requires-Dist: sqlglot>=25.27.0
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
1
30
|
# sqlnbfmt
|
|
2
31
|
|
|
3
32
|
[](https://pypi.org/project/sqlnbfmt/)
|
|
33
|
+
[](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml)
|
|
4
34
|
[](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
|
|
5
35
|
[](https://pypi.org/project/sqlnbfmt/)
|
|
6
36
|
|
|
7
37
|
> **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
|
|
8
38
|
|
|
9
|
-
A SQL formatter
|
|
39
|
+
A SQL formatter for Jupyter Notebooks and Marimo notebooks. `sqlnbfmt` automatically formats SQL queries embedded in notebook cells — Python strings, SQL magic cells (`%%sql`), and Marimo `mo.sql()` calls — helping you maintain clean and consistent code.
|
|
10
40
|
|
|
11
41
|
## Features
|
|
12
42
|
|
|
13
43
|
- **Zero-config**: Works out of the box with sensible defaults — no config file needed
|
|
14
|
-
- **
|
|
44
|
+
- **Jupyter + Marimo**: Formats SQL in `.ipynb` notebooks and Marimo `.py` notebooks
|
|
45
|
+
- **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells, magic SQL cells, and `mo.sql()` calls
|
|
15
46
|
- **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
|
|
47
|
+
- **Safe Formatting**: Preserves Python comments, query parameters (e.g., `%s`, `?`), f-string placeholders, and SQL comments
|
|
17
48
|
- **CI-Friendly**: `--check` mode exits non-zero when formatting is needed; `--diff` shows what would change
|
|
18
49
|
- **Skip Hints**: Add `# sqlnbfmt: skip` to any cell to exclude it from formatting
|
|
19
50
|
- **Pre-commit Ready**: Seamlessly integrates with pre-commit hooks
|
|
@@ -29,14 +60,24 @@ pip install sqlnbfmt
|
|
|
29
60
|
|
|
30
61
|
### Command Line
|
|
31
62
|
|
|
32
|
-
Format
|
|
63
|
+
Format Jupyter notebooks:
|
|
33
64
|
```bash
|
|
34
65
|
sqlnbfmt path/to/your_notebook.ipynb
|
|
35
66
|
```
|
|
36
67
|
|
|
68
|
+
Format Marimo notebooks (or any Python file with SQL strings):
|
|
69
|
+
```bash
|
|
70
|
+
sqlnbfmt path/to/your_notebook.py
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Mix both in a single invocation:
|
|
74
|
+
```bash
|
|
75
|
+
sqlnbfmt *.ipynb marimo_app.py
|
|
76
|
+
```
|
|
77
|
+
|
|
37
78
|
Check formatting without modifying files (useful in CI):
|
|
38
79
|
```bash
|
|
39
|
-
sqlnbfmt --check path/to/your_notebook.ipynb
|
|
80
|
+
sqlnbfmt --check path/to/your_notebook.ipynb path/to/marimo_app.py
|
|
40
81
|
```
|
|
41
82
|
|
|
42
83
|
Show a diff of what would change:
|
|
@@ -46,7 +87,7 @@ sqlnbfmt --diff path/to/your_notebook.ipynb
|
|
|
46
87
|
|
|
47
88
|
### Skipping Cells
|
|
48
89
|
|
|
49
|
-
Add a `# sqlnbfmt: skip` comment
|
|
90
|
+
Add a `# sqlnbfmt: skip` comment to skip formatting. In Jupyter notebooks this applies per-cell; in Python files it applies to the entire file.
|
|
50
91
|
|
|
51
92
|
```python
|
|
52
93
|
# sqlnbfmt: skip
|
|
@@ -64,12 +105,18 @@ pip install pre-commit
|
|
|
64
105
|
```yaml
|
|
65
106
|
repos:
|
|
66
107
|
- repo: https://github.com/flyersworder/sqlnbfmt
|
|
67
|
-
rev: v0.
|
|
108
|
+
rev: v0.4.0
|
|
68
109
|
hooks:
|
|
69
110
|
- id: sqlnbfmt
|
|
70
111
|
types: [jupyter]
|
|
71
112
|
```
|
|
72
113
|
|
|
114
|
+
To also format Marimo notebooks (Python files), add the Python hook:
|
|
115
|
+
```yaml
|
|
116
|
+
- id: sqlnbfmt-py
|
|
117
|
+
files: '\.py$' # optionally narrow with: files: 'marimo_.*\.py$'
|
|
118
|
+
```
|
|
119
|
+
|
|
73
120
|
All arguments are optional. To specify a dialect or custom config:
|
|
74
121
|
```yaml
|
|
75
122
|
args: [--dialect, postgres, --config, config.yaml]
|
|
@@ -94,6 +141,7 @@ Use `--check` in GitHub Actions to enforce formatting:
|
|
|
94
141
|
run: |
|
|
95
142
|
pip install sqlnbfmt
|
|
96
143
|
sqlnbfmt --check **/*.ipynb
|
|
144
|
+
sqlnbfmt --check marimo_*.py # if using Marimo
|
|
97
145
|
```
|
|
98
146
|
|
|
99
147
|
## Configuration
|
|
@@ -112,7 +160,9 @@ Create a `config.yaml` file to customize formatting behavior. [Here](https://git
|
|
|
112
160
|
| `single_line_threshold` | Maximum length before splitting SQL | 80 |
|
|
113
161
|
| `indent_width` | Number of spaces for indentation | 4 |
|
|
114
162
|
|
|
115
|
-
##
|
|
163
|
+
## Examples
|
|
164
|
+
|
|
165
|
+
### Jupyter Notebook
|
|
116
166
|
|
|
117
167
|
Before formatting:
|
|
118
168
|
```python
|
|
@@ -137,6 +187,35 @@ ORDER BY
|
|
|
137
187
|
""")
|
|
138
188
|
```
|
|
139
189
|
|
|
190
|
+
### Marimo Notebook
|
|
191
|
+
|
|
192
|
+
Before formatting:
|
|
193
|
+
```python
|
|
194
|
+
@app.cell
|
|
195
|
+
def _(mo):
|
|
196
|
+
_df = mo.sql(f"select id, name from users where active = 1 order by name")
|
|
197
|
+
return
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
After formatting:
|
|
201
|
+
```python
|
|
202
|
+
@app.cell
|
|
203
|
+
def _(mo):
|
|
204
|
+
_df = mo.sql(
|
|
205
|
+
f"""
|
|
206
|
+
SELECT
|
|
207
|
+
id,
|
|
208
|
+
name
|
|
209
|
+
FROM users
|
|
210
|
+
WHERE
|
|
211
|
+
active = 1
|
|
212
|
+
ORDER BY
|
|
213
|
+
name
|
|
214
|
+
"""
|
|
215
|
+
)
|
|
216
|
+
return
|
|
217
|
+
```
|
|
218
|
+
|
|
140
219
|
## Troubleshooting
|
|
141
220
|
|
|
142
221
|
**SQL not being formatted?**
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Fixture-based eval test suite for sqlnbfmt.
|
|
2
2
|
|
|
3
3
|
Auto-discovers test cases from tests/eval/*/ directories.
|
|
4
|
-
|
|
4
|
+
Jupyter cases: input.ipynb + expected.ipynb.
|
|
5
|
+
Marimo/Python cases: input.py + expected.py.
|
|
5
6
|
"""
|
|
6
7
|
|
|
7
8
|
import logging
|
|
@@ -11,7 +12,7 @@ from pathlib import Path
|
|
|
11
12
|
import nbformat
|
|
12
13
|
import pytest
|
|
13
14
|
|
|
14
|
-
from sqlnbfmt.formatter import load_config, process_notebook
|
|
15
|
+
from sqlnbfmt.formatter import load_config, process_notebook, process_python_file
|
|
15
16
|
|
|
16
17
|
EVAL_DIR = Path(__file__).resolve().parent / "eval"
|
|
17
18
|
DIALECT = "mysql"
|
|
@@ -78,3 +79,35 @@ def test_eval(case_name, logger, tmp_path):
|
|
|
78
79
|
f"Expected:\n{exp_cell.source}\n"
|
|
79
80
|
f"Actual:\n{fmt_cell.source}"
|
|
80
81
|
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discover_marimo_eval_cases():
|
|
85
|
+
"""Find eval case directories containing input.py + expected.py."""
|
|
86
|
+
if not EVAL_DIR.is_dir():
|
|
87
|
+
return []
|
|
88
|
+
cases = []
|
|
89
|
+
for case_dir in sorted(EVAL_DIR.iterdir()):
|
|
90
|
+
if (
|
|
91
|
+
case_dir.is_dir()
|
|
92
|
+
and (case_dir / "input.py").exists()
|
|
93
|
+
and (case_dir / "expected.py").exists()
|
|
94
|
+
):
|
|
95
|
+
cases.append(case_dir.name)
|
|
96
|
+
return cases
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.parametrize("case_name", discover_marimo_eval_cases(), ids=lambda c: c)
|
|
100
|
+
def test_marimo_eval(case_name, logger, tmp_path):
|
|
101
|
+
"""Run formatter on input.py and compare with expected.py."""
|
|
102
|
+
case_dir = EVAL_DIR / case_name
|
|
103
|
+
work_path = tmp_path / "notebook.py"
|
|
104
|
+
shutil.copy2(case_dir / "input.py", work_path)
|
|
105
|
+
|
|
106
|
+
config = load_config()
|
|
107
|
+
process_python_file(work_path, config, DIALECT, logger)
|
|
108
|
+
|
|
109
|
+
formatted = work_path.read_text()
|
|
110
|
+
expected = (case_dir / "expected.py").read_text()
|
|
111
|
+
assert formatted == expected, (
|
|
112
|
+
f"[{case_name}] Content mismatch.\nExpected:\n{expected}\nActual:\n{formatted}"
|
|
113
|
+
)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from sqlnbfmt.formatter import (
|
|
7
|
+
_format_python_source,
|
|
8
|
+
diff_python_file,
|
|
9
|
+
load_config,
|
|
10
|
+
process_python_file,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def logger():
|
|
16
|
+
log = logging.getLogger("test_marimo")
|
|
17
|
+
log.setLevel(logging.DEBUG)
|
|
18
|
+
if not log.handlers:
|
|
19
|
+
handler = logging.StreamHandler()
|
|
20
|
+
handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
|
21
|
+
log.addHandler(handler)
|
|
22
|
+
return log
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# _format_python_source unit tests
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_format_mo_sql_basic(logger):
|
|
31
|
+
"""mo.sql() with a simple SELECT is formatted."""
|
|
32
|
+
source = """import marimo as mo
|
|
33
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
34
|
+
"""
|
|
35
|
+
config = load_config()
|
|
36
|
+
result, changed = _format_python_source(source, config, "mysql", logger)
|
|
37
|
+
assert changed
|
|
38
|
+
assert "SELECT" in result
|
|
39
|
+
assert "FROM users" in result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_format_mo_sql_fstring_placeholder(logger):
|
|
43
|
+
"""mo.sql() with f-string {expr} placeholders preserved."""
|
|
44
|
+
source = """import marimo as mo
|
|
45
|
+
table = "users"
|
|
46
|
+
_df = mo.sql(f"select * from {table} where active = 1")
|
|
47
|
+
"""
|
|
48
|
+
config = load_config()
|
|
49
|
+
result, changed = _format_python_source(source, config, "mysql", logger)
|
|
50
|
+
assert changed
|
|
51
|
+
assert "{table}" in result
|
|
52
|
+
assert "SELECT" in result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_format_non_sql_unchanged(logger):
|
|
56
|
+
"""Non-SQL Python code is not modified."""
|
|
57
|
+
source = """x = 1
|
|
58
|
+
y = "hello world"
|
|
59
|
+
print(x + y)
|
|
60
|
+
"""
|
|
61
|
+
config = load_config()
|
|
62
|
+
result, changed = _format_python_source(source, config, "mysql", logger)
|
|
63
|
+
assert not changed
|
|
64
|
+
assert result == source
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_format_idempotent(logger):
|
|
68
|
+
"""Formatting already-formatted output produces no changes."""
|
|
69
|
+
source = """import marimo as mo
|
|
70
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
71
|
+
"""
|
|
72
|
+
config = load_config()
|
|
73
|
+
result1, changed1 = _format_python_source(source, config, "mysql", logger)
|
|
74
|
+
assert changed1
|
|
75
|
+
result2, changed2 = _format_python_source(result1, config, "mysql", logger)
|
|
76
|
+
assert not changed2
|
|
77
|
+
assert result2 == result1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_format_idempotent_fstring(logger):
|
|
81
|
+
"""Idempotency holds for f-strings with placeholders."""
|
|
82
|
+
source = """import marimo as mo
|
|
83
|
+
table = "users"
|
|
84
|
+
_df = mo.sql(f"select * from {table} where active = 1")
|
|
85
|
+
"""
|
|
86
|
+
config = load_config()
|
|
87
|
+
result1, _ = _format_python_source(source, config, "mysql", logger)
|
|
88
|
+
result2, changed2 = _format_python_source(result1, config, "mysql", logger)
|
|
89
|
+
assert not changed2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_format_skip_hint(logger):
|
|
93
|
+
"""File with skip hint is not modified."""
|
|
94
|
+
source = """# sqlnbfmt: skip
|
|
95
|
+
import marimo as mo
|
|
96
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
97
|
+
"""
|
|
98
|
+
config = load_config()
|
|
99
|
+
result, changed = _format_python_source(source, config, "mysql", logger)
|
|
100
|
+
assert not changed
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_format_empty_source(logger):
|
|
104
|
+
"""Empty source returns unchanged."""
|
|
105
|
+
config = load_config()
|
|
106
|
+
result, changed = _format_python_source("", config, "mysql", logger)
|
|
107
|
+
assert not changed
|
|
108
|
+
assert result == ""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_format_syntax_error(logger):
|
|
112
|
+
"""Invalid Python source returns unchanged."""
|
|
113
|
+
source = "def foo(:\n"
|
|
114
|
+
config = load_config()
|
|
115
|
+
result, changed = _format_python_source(source, config, "mysql", logger)
|
|
116
|
+
assert not changed
|
|
117
|
+
assert result == source
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_format_plain_python_with_sql(logger):
|
|
121
|
+
"""Non-Marimo Python file with cursor.execute() is also formatted."""
|
|
122
|
+
source = """import sqlite3
|
|
123
|
+
conn = sqlite3.connect("db.sqlite")
|
|
124
|
+
cursor = conn.cursor()
|
|
125
|
+
cursor.execute("select id, name from users where active = 1")
|
|
126
|
+
"""
|
|
127
|
+
config = load_config()
|
|
128
|
+
result, changed = _format_python_source(source, config, "mysql", logger)
|
|
129
|
+
assert changed
|
|
130
|
+
assert "SELECT" in result
|
|
131
|
+
assert "FROM users" in result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# process_python_file / diff_python_file tests
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_process_python_file(tmp_path, logger):
|
|
140
|
+
"""process_python_file formats and writes back."""
|
|
141
|
+
source = """import marimo as mo
|
|
142
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
143
|
+
"""
|
|
144
|
+
py_path = tmp_path / "notebook.py"
|
|
145
|
+
py_path.write_text(source)
|
|
146
|
+
|
|
147
|
+
config = load_config()
|
|
148
|
+
changed = process_python_file(py_path, config, "mysql", logger)
|
|
149
|
+
assert changed
|
|
150
|
+
|
|
151
|
+
result = py_path.read_text()
|
|
152
|
+
assert "SELECT" in result
|
|
153
|
+
assert "FROM users" in result
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_process_python_file_check_only(tmp_path, logger):
|
|
157
|
+
"""check_only=True detects changes without writing."""
|
|
158
|
+
source = """import marimo as mo
|
|
159
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
160
|
+
"""
|
|
161
|
+
py_path = tmp_path / "notebook.py"
|
|
162
|
+
py_path.write_text(source)
|
|
163
|
+
|
|
164
|
+
config = load_config()
|
|
165
|
+
changed = process_python_file(py_path, config, "mysql", logger, check_only=True)
|
|
166
|
+
assert changed
|
|
167
|
+
# File unchanged
|
|
168
|
+
assert py_path.read_text() == source
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_diff_python_file(tmp_path, logger):
|
|
172
|
+
"""diff_python_file returns unified diff."""
|
|
173
|
+
source = """import marimo as mo
|
|
174
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
175
|
+
"""
|
|
176
|
+
py_path = tmp_path / "notebook.py"
|
|
177
|
+
py_path.write_text(source)
|
|
178
|
+
|
|
179
|
+
config = load_config()
|
|
180
|
+
diff = diff_python_file(py_path, config, "mysql", logger)
|
|
181
|
+
assert diff
|
|
182
|
+
assert "---" in diff
|
|
183
|
+
assert "+++" in diff
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_diff_python_file_no_changes(tmp_path, logger):
|
|
187
|
+
"""diff_python_file returns empty string when no changes needed."""
|
|
188
|
+
source = """x = 1
|
|
189
|
+
y = "hello"
|
|
190
|
+
"""
|
|
191
|
+
py_path = tmp_path / "notebook.py"
|
|
192
|
+
py_path.write_text(source)
|
|
193
|
+
|
|
194
|
+
config = load_config()
|
|
195
|
+
diff = diff_python_file(py_path, config, "mysql", logger)
|
|
196
|
+
assert diff == ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# CLI integration tests
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_cli_marimo_format(tmp_path):
|
|
205
|
+
"""CLI formats a .py file."""
|
|
206
|
+
source = """import marimo as mo
|
|
207
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
208
|
+
"""
|
|
209
|
+
py_path = tmp_path / "notebook.py"
|
|
210
|
+
py_path.write_text(source)
|
|
211
|
+
|
|
212
|
+
result = subprocess.run(
|
|
213
|
+
["sqlnbfmt", str(py_path)],
|
|
214
|
+
capture_output=True,
|
|
215
|
+
text=True,
|
|
216
|
+
)
|
|
217
|
+
assert result.returncode == 0
|
|
218
|
+
formatted = py_path.read_text()
|
|
219
|
+
assert "SELECT" in formatted
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_cli_marimo_check(tmp_path):
|
|
223
|
+
"""CLI --check on unformatted .py exits 1."""
|
|
224
|
+
source = """import marimo as mo
|
|
225
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
226
|
+
"""
|
|
227
|
+
py_path = tmp_path / "notebook.py"
|
|
228
|
+
py_path.write_text(source)
|
|
229
|
+
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
["sqlnbfmt", "--check", str(py_path)],
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
)
|
|
235
|
+
assert result.returncode == 1
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_cli_marimo_diff(tmp_path):
|
|
239
|
+
"""CLI --diff on .py prints diff and exits 0."""
|
|
240
|
+
source = """import marimo as mo
|
|
241
|
+
_df = mo.sql(f"select id, name from users where active = 1")
|
|
242
|
+
"""
|
|
243
|
+
py_path = tmp_path / "notebook.py"
|
|
244
|
+
py_path.write_text(source)
|
|
245
|
+
|
|
246
|
+
result = subprocess.run(
|
|
247
|
+
["sqlnbfmt", "--diff", str(py_path)],
|
|
248
|
+
capture_output=True,
|
|
249
|
+
text=True,
|
|
250
|
+
)
|
|
251
|
+
assert result.returncode == 0
|
|
252
|
+
assert "---" in result.stdout
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_cli_mixed_files(tmp_path):
|
|
256
|
+
"""CLI handles .ipynb and .py files in the same invocation."""
|
|
257
|
+
import nbformat
|
|
258
|
+
from nbformat import v4 as nbf
|
|
259
|
+
|
|
260
|
+
# Create .ipynb
|
|
261
|
+
nb = nbf.new_notebook()
|
|
262
|
+
nb.cells = [nbf.new_code_cell('query = "select id from users where active = 1"')]
|
|
263
|
+
nb_path = tmp_path / "notebook.ipynb"
|
|
264
|
+
nbformat.write(nb, nb_path)
|
|
265
|
+
|
|
266
|
+
# Create .py
|
|
267
|
+
py_source = """import marimo as mo
|
|
268
|
+
_df = mo.sql(f"select id from users where active = 1")
|
|
269
|
+
"""
|
|
270
|
+
py_path = tmp_path / "notebook.py"
|
|
271
|
+
py_path.write_text(py_source)
|
|
272
|
+
|
|
273
|
+
result = subprocess.run(
|
|
274
|
+
["sqlnbfmt", str(nb_path), str(py_path)],
|
|
275
|
+
capture_output=True,
|
|
276
|
+
text=True,
|
|
277
|
+
)
|
|
278
|
+
assert result.returncode == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|