sqlnbfmt 0.3.2__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.
@@ -1,17 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlnbfmt
3
- Version: 0.3.2
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
  [![PyPI](https://img.shields.io/pypi/v/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
33
+ [![Tests](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml/badge.svg)](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml)
26
34
  [![License](https://img.shields.io/pypi/l/sqlnbfmt.svg)](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
27
35
  [![Python Versions](https://img.shields.io/pypi/pyversions/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
28
36
 
29
37
  > **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
30
38
 
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.
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
- - **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
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 a single notebook:
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 anywhere in a cell to skip formatting:
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.3.0
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
- ## Example
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.2
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
  [![PyPI](https://img.shields.io/pypi/v/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
4
+ [![Tests](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml/badge.svg)](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml)
26
5
  [![License](https://img.shields.io/pypi/l/sqlnbfmt.svg)](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
27
6
  [![Python Versions](https://img.shields.io/pypi/pyversions/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
28
7
 
29
8
  > **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
30
9
 
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.
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
- - **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
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 a single notebook:
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 anywhere in a cell to skip formatting:
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.3.0
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
- ## Example
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.3.2"
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,6 +4,7 @@ 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
@@ -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
 
@@ -359,7 +361,7 @@ class SQLStringFormatter(ast.NodeVisitor):
359
361
  except SQLFormattingError:
360
362
  return None
361
363
 
362
- if formatted_sql == sql_str:
364
+ if formatted_sql == textwrap.dedent(sql_str).strip():
363
365
  return None
364
366
 
365
367
  # Restore placeholders as f-string expressions
@@ -574,6 +576,47 @@ def _format_cells(
574
576
  return changed
575
577
 
576
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
+
577
620
  def process_notebook(
578
621
  notebook_path: Union[str, Path],
579
622
  config: FormattingConfig,
@@ -634,13 +677,69 @@ def diff_notebook(
634
677
  return "".join(diff_lines)
635
678
 
636
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
+
637
731
  def main():
638
732
  """Main entry point for the SQL formatter."""
639
733
  import argparse
640
734
 
641
- parser = argparse.ArgumentParser(description="Format SQL code in Jupyter notebooks")
735
+ parser = argparse.ArgumentParser(
736
+ description="Format SQL code in Jupyter notebooks and Python files"
737
+ )
642
738
  parser.add_argument(
643
- "notebooks", nargs="+", type=Path, help="Notebook paths to process"
739
+ "files",
740
+ nargs="+",
741
+ type=Path,
742
+ help="Notebook (.ipynb) or Python (.py) file paths to process",
644
743
  )
645
744
  parser.add_argument(
646
745
  "--config", type=Path, default=None, help="Configuration file path (optional)"
@@ -679,41 +778,51 @@ def main():
679
778
  try:
680
779
  changed_files = []
681
780
 
682
- for notebook in args.notebooks:
781
+ for file_path in args.files:
782
+ is_python = file_path.suffix == ".py"
783
+
683
784
  if args.check or args.diff:
684
- # In check/diff mode, use diff_notebook to get both the
685
- # diff output and whether changes are needed (single pass).
686
- diff_output = diff_notebook(notebook, config, args.dialect, logger)
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)
687
791
  needs_formatting = bool(diff_output)
688
792
 
689
793
  if args.diff and diff_output:
690
794
  print(diff_output, end="")
691
795
 
692
796
  if needs_formatting:
693
- changed_files.append(notebook)
797
+ changed_files.append(file_path)
694
798
  else:
695
- # Normal mode: format in place
696
- if process_notebook(notebook, config, args.dialect, logger):
697
- changed_files.append(notebook)
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)
698
807
 
699
808
  if args.check:
700
809
  if changed_files:
701
- logger.error(f"{len(changed_files)} notebook(s) would be reformatted:")
810
+ logger.error(f"{len(changed_files)} file(s) would be reformatted:")
702
811
  for f in changed_files:
703
812
  logger.error(f" - {f}")
704
813
  sys.exit(1)
705
814
  else:
706
- logger.info("All notebooks are properly formatted.")
815
+ logger.info("All files are properly formatted.")
707
816
  sys.exit(0)
708
817
 
709
818
  # Print summary for normal mode
710
819
  if not args.diff:
711
820
  if changed_files:
712
- logger.info("Changes made to the following notebooks:")
821
+ logger.info("Changes made to the following files:")
713
822
  for file in changed_files:
714
823
  logger.info(f" - {file}")
715
824
  else:
716
- logger.info("No changes needed. All notebooks are properly formatted.")
825
+ logger.info("No changes needed. All files are properly formatted.")
717
826
 
718
827
  sys.exit(0)
719
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
  [![PyPI](https://img.shields.io/pypi/v/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
33
+ [![Tests](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml/badge.svg)](https://github.com/flyersworder/sqlnbfmt/actions/workflows/ci-and-publish.yml)
4
34
  [![License](https://img.shields.io/pypi/l/sqlnbfmt.svg)](https://github.com/flyersworder/sqlnbfmt/blob/main/LICENSE)
5
35
  [![Python Versions](https://img.shields.io/pypi/pyversions/sqlnbfmt.svg)](https://pypi.org/project/sqlnbfmt/)
6
36
 
7
37
  > **Quick start:** `pip install sqlnbfmt && sqlnbfmt notebook.ipynb`
8
38
 
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.
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
- - **Smart SQL Detection**: Automatically identifies and formats SQL queries in code cells and magic SQL cells
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 a single notebook:
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 anywhere in a cell to skip formatting:
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.3.0
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
- ## Example
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?**
@@ -10,4 +10,5 @@ sqlnbfmt.egg-info/entry_points.txt
10
10
  sqlnbfmt.egg-info/requires.txt
11
11
  sqlnbfmt.egg-info/top_level.txt
12
12
  tests/test_eval.py
13
- tests/test_formatter.py
13
+ tests/test_formatter.py
14
+ tests/test_marimo.py
@@ -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
- Each directory must contain input.ipynb and expected.ipynb.
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