Skip to content

Commit e7201e1

Browse files
authored
feat: add # fmt: off to disable formatting (#280)
closes #86 * feat: allow # fmt: off * fix: tests * fix: strict * feat: off[sort] * fix: ugly reset indents before process keywords * feat: off[next] * fix: clean * docs: fmt: off * style: sort imports * fix * fix: prompt * fix: more tests * typo * style: flake8 * fix: docs * fix: typo * fix: improve handling of fmt: off directives and update related tests * fix: address review * fix: docs * make flask8 happy * docs: clear
1 parent d871276 commit e7201e1

6 files changed

Lines changed: 1710 additions & 255 deletions

File tree

README.md

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ design and specifications of [Black][black].
1717
> `--diff` or `--check` options. See [Usage](#usage) for more details.
1818
1919
> [!IMPORTANT]
20-
> **Recent Changes:**
20+
> **Recent Changes:**
2121
> 1. **Rule and module directives are now sorted by default:** `snakefmt` will automatically sort the order of directives inside rules (e.g. `input`, `output`, `shell`) and modules into a consistent order. You can opt out of this by using the `--no-sort` CLI flag.
2222
> 2. **Black upgraded to v26:** The underlying `black` formatter has been upgraded to v26. You will see changes in how implicitly concatenated strings are wrapped (they are now collapsed onto a single line if they fit within the line limit) and other minor adjustments compared to previous versions.
23-
>
23+
>
2424
> **Example of expected differences:**
2525
> ```python
2626
> # Before (Snakefmt older versions)
@@ -33,7 +33,7 @@ design and specifications of [Black][black].
3333
> "b.txt",
3434
> input:
3535
> "a.txt",
36-
>
36+
>
3737
> # After (Directives sorted, strings collapsed by Black 26)
3838
> rule example:
3939
> input:
@@ -56,13 +56,16 @@ design and specifications of [Black][black].
5656
- [Usage](#usage)
5757
- [Basic Usage](#basic-usage)
5858
- [Full Usage](#full-usage)
59-
- [Configuration](#configuration)
6059
- [Directive Sorting](#directive-sorting)
60+
- [Format Directives](#format-directives)
61+
- [Configuration](#configuration)
6162
- [Integration](#integration)
62-
- [Editor Integration](#editor-integration)
63-
- [Version Control Integration](#version-control-integration)
64-
- [Github Actions](#github-actions)
63+
- [Editor Integration](#editor-integration)
64+
- [Version Control Integration](#version-control-integration)
65+
- [GitHub Actions](#github-actions)
6566
- [Plug Us](#plug-us)
67+
- [Markdown](#markdown)
68+
- [ReStructuredText](#restructuredtext)
6669
- [Changes](#changes)
6770
- [Contributing](#contributing)
6871
- [Cite](#cite)
@@ -280,20 +283,6 @@ Options:
280283
-v, --verbose Turns on debug-level logger.
281284
```
282285
283-
## Configuration
284-
285-
`snakefmt` is able to read project-specific default values for its command line options
286-
from a `pyproject.toml` file. In addition, it will also load any [`black`
287-
configurations][black-config] you have in the same file.
288-
289-
By default, `snakefmt` will search in the parent directories of the formatted file(s)
290-
for a file called `pyproject.toml` and use any configuration there.
291-
If your configuration file is located somewhere else or called something different,
292-
specify it using `--config`.
293-
294-
Any options you pass on the command line will take precedence over default values in the
295-
configuration file.
296-
297286
### Directive Sorting
298287
299288
By default, `snakefmt` sorts rule and module directives (like `input`, `output`, `shell`, etc.) into a consistent order. This makes rules easier to read and allows for quicker cross-referencing between inputs, outputs, and the resources used by the execution command.
@@ -313,9 +302,104 @@ This ordering ensures that the directives most frequently used in execution bloc
313302
314303
You can disable this feature using the `--no-sort` flag.
315304
305+
### Format Directives
306+
307+
`snakefmt` supports comment directives to control formatting behaviour for specific regions of code.
308+
Directives should appear as standalone comment lines, an inline occurrence (e.g. `input: # fmt: off`) is treated as a plain comment and has no effect.
309+
All directives are scope-local: only the region they select is affected, while code before and after follows normal `snakefmt` formatting and spacing rules (equivalent to replacing the directive with a plain comment line).
310+
311+
#### `# fmt: off` / `# fmt: on`
312+
313+
Disables all formatting for the region between the two directives.
314+
Both directives *must* appear at the same indentation level; a `# fmt: on` at a deeper indent than the matching `# fmt: off` has no effect.
315+
316+
```python
317+
rule a:
318+
input:
319+
"a.txt",
320+
321+
322+
# fmt: off
323+
rule b:
324+
input: "b.txt"
325+
output:
326+
"c.txt"
327+
# fmt: on
328+
329+
330+
rule c:
331+
input:
332+
"d.txt",
333+
```
334+
335+
> **Note:** inside `run:` blocks and other Python contexts, `# fmt: off` / `# fmt: on` is passed through to [Black][black], which handles it natively.
336+
337+
#### `# fmt: off[sort]`
338+
339+
Disables directive sorting for the enclosed region while still applying all other formatting.
340+
Directives between `# fmt: off[sort]` and `# fmt: on[sort]` are kept in their original order.
341+
A plain `# fmt: on` also closes a `# fmt: off[sort]` region.
342+
343+
```python
344+
# fmt: off[sort]
345+
rule keep_my_order:
346+
output:
347+
"result.txt",
348+
input:
349+
"source.txt",
350+
shell:
351+
"cp {input} {output}"
352+
# fmt: on[sort]
353+
```
354+
355+
#### `# fmt: off[next]`
356+
357+
Disables formatting for the single next Snakemake keyword block (e.g. `rule`, `checkpoint`, `use rule`).
358+
Only that block is left unformatted; all subsequent blocks are formatted normally.
359+
360+
```python
361+
rule formatted:
362+
input:
363+
"a.txt",
364+
output:
365+
"b.txt",
366+
367+
368+
# fmt: off[next]
369+
rule unformatted:
370+
input: "a.txt"
371+
output: "b.txt"
372+
373+
374+
rule also_formatted:
375+
input:
376+
"a.txt",
377+
```
378+
379+
#### `# fmt: skip`
380+
381+
`# fmt: skip` preserves a single line exactly as written, without any formatting (see [Black's documentation][black-skip] for details).
382+
383+
> **Note:** `# fmt: skip` is not yet supported within Snakemake rule blocks.
384+
> It currently applies only to plain Python lines outside of rules, checkpoints, and similar Snakemake constructs.
385+
386+
### Configuration
387+
388+
`snakefmt` is able to read project-specific default values for its command line options
389+
from a `pyproject.toml` file. In addition, it will also load any [`black`
390+
configurations][black-config] you have in the same file.
391+
392+
By default, `snakefmt` will search in the parent directories of the formatted file(s)
393+
for a file called `pyproject.toml` and use any configuration there.
394+
If your configuration file is located somewhere else or called something different,
395+
specify it using `--config`.
396+
397+
Any options you pass on the command line will take precedence over default values in the
398+
configuration file.
399+
316400
#### Example
317401
318-
`pyproject.toml`
402+
[`pyproject.toml`][pyproject]
319403
320404
```toml
321405
[tool.snakefmt]
@@ -415,13 +499,13 @@ in your project.
415499
416500
[![Code style: snakefmt](https://img.shields.io/badge/code%20style-snakefmt-000000.svg)](https://github.com/snakemake/snakefmt)
417501
418-
#### Markdown
502+
### Markdown
419503
420504
```md
421505
[![Code style: snakefmt](https://img.shields.io/badge/code%20style-snakefmt-000000.svg)](https://github.com/snakemake/snakefmt)
422506
```
423507
424-
#### ReStructuredText
508+
### ReStructuredText
425509
426510
```rst
427511
.. image:: https://img.shields.io/badge/code%20style-snakefmt-000000.svg
@@ -459,6 +543,7 @@ See [CONTRIBUTING.md][contributing].
459543
[snakemake]: https://snakemake.readthedocs.io/
460544
[black]: https://black.readthedocs.io/en/stable/
461545
[black-config]: https://github.com/psf/black#pyprojecttoml
546+
[black-skip]: https://black.readthedocs.io/en/stable/usage_and_configuration/the_basics.html#ignoring-sections
462547
[pyproject]: https://github.com/snakemake/snakefmt/blob/master/pyproject.toml
463548
[contributing]: CONTRIBUTING.md
464549
[changes]: CHANGELOG.md

snakefmt/formatter.py

Lines changed: 106 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ def __init__(
6565
self.result: str = ""
6666
self.lagging_comments: str = ""
6767
self.no_formatting_yet: bool = True
68-
self.sort_directives = sort_directives
6968
self.previous_result: str = ""
7069
self.keyword_spec: list[str] = []
7170
self.keywords: dict[str, str] = {} # cache to sort
@@ -75,7 +74,7 @@ def __init__(
7574
if line_length is not None:
7675
self.black_mode.line_length = line_length
7776

78-
super().__init__(snakefile) # Call to parse snakefile
77+
super().__init__(snakefile, sort_directives=sort_directives)
7978

8079
def get_formatted(self) -> str:
8180
return self.result
@@ -90,10 +89,13 @@ def flush_buffer(
9089
from_python: bool = False,
9190
final_flush: bool = False,
9291
in_global_context: bool = False,
92+
exiting_keywords: bool = False,
9393
) -> None:
9494
if len(self.buffer) == 0 or self.buffer.isspace():
9595
self.result += self.buffer
9696
self.buffer = ""
97+
if exiting_keywords and self.no_formatting_yet and self.result.rstrip("\n"):
98+
self.no_formatting_yet = False
9799
return
98100

99101
if not from_python:
@@ -103,6 +105,9 @@ def flush_buffer(
103105
else:
104106
# Invalid python syntax, eg lone 'else:' between two rules, can occur.
105107
# Below constructs valid code statements and formats them.
108+
if self.fmt_off_expected_indent:
109+
self.buffer += self.fmt_off_expected_indent
110+
self.fmt_off_expected_indent = ""
106111
re_match = contextual_matcher.match(self.buffer)
107112
if re_match is not None:
108113
callback_keyword = re_match.group(2)
@@ -119,11 +124,13 @@ def flush_buffer(
119124
)
120125
formatted = self.run_black_format_str(to_format, self.block_indent)
121126
re_rematch = contextual_matcher.match(formatted)
122-
if re_rematch is None:
123-
raise ValueError(
124-
"contextual_matcher failed to match for the given "
125-
f"formatted string: {formatted}"
126-
)
127+
assert re_rematch, (
128+
"This should always match as we just formatted it with the same "
129+
"regex. If this error is raised, it's a bug in snakefmt's "
130+
"handling of snakemake syntax. Please report this to the "
131+
"developers with the code so we can fix it: "
132+
"https://github.com/snakemake/snakefmt/issues"
133+
)
127134
if condition != "":
128135
callback_keyword += re_rematch.group(3)
129136
formatted = (
@@ -174,7 +181,7 @@ def process_keyword_param(
174181
context=param_context,
175182
)
176183
param_formatted = self.format_params(param_context)
177-
if self.sort_directives and not in_global_context and self.keyword_spec:
184+
if self.sort_off_indent is None and not in_global_context and self.keyword_spec:
178185
self.keywords[param_context.keyword_name] = self.result + param_formatted
179186
self.result = ""
180187
else:
@@ -188,13 +195,95 @@ def post_process_keyword(self):
188195
for keyword in self.keyword_spec:
189196
res = self.keywords.pop(keyword, "")
190197
self.previous_result += res
191-
if self.keywords:
192-
raise InvalidParameterSyntax(
193-
"Unexpected keywords when sorted keywords: "
194-
+ (", ".join(self.keywords))
195-
)
198+
assert not self.keywords, (
199+
"All directives should have been consumed; "
200+
"if not, this is a bug in snakefmt's handling of snakemake syntax. "
201+
"It must be the coder's fault, not the user's. "
202+
"So please report this to the developers with the code so we can fix it: "
203+
"https://github.com/snakemake/snakefmt/issues"
204+
)
196205
self.result = self.previous_result + self.result
197206
self.previous_result = ""
207+
# Keep no_formatting_yet when there is pending buffered content.
208+
# This prevents premature separator insertion after fmt: off/on
209+
# verbatim regions before the next flush occurs.
210+
if self.no_formatting_yet and self.result.rstrip("\n") and not self.buffer:
211+
self.no_formatting_yet = False
212+
213+
def flush_fmt_off_region(self, verbatim: str):
214+
"""Blank-line rules:
215+
216+
applied before the verbatim block:
217+
- At global indent (fmt_off[0] == 0) and result not empty:
218+
result should end with exactly 2 blank lines (``\\n\\n\\n``)
219+
(standard separation between top-level constructs).
220+
- When the preceding Python code had a blank line before ``# fmt: off``
221+
(``fmt_off_preceded_by_blank_line``):
222+
result should end with >= 1 blank line.
223+
- ``# fmt: off[next]`` nested inside a Python block:
224+
another ``\\n`` is prepended to any lagging comment
225+
so the following keyword gets its normal blank-line separator.
226+
227+
applied after the verbatim block:
228+
- ``# fmt: off[next]``: sets ``no_formatting_yet := False``,
229+
so the next formatted block gets its normal blank-line separator.
230+
- Plain ``# fmt: off`` regions: sets ``no_formatting_yet := True``,
231+
suppressing blank-line insertion in the next ``add_newlines`` call.
232+
"""
233+
234+
if self.no_formatting_yet:
235+
self.result = self.result.lstrip("\n")
236+
self.result += self.buffer
237+
self.buffer = ""
238+
if self.fmt_off:
239+
if self.fmt_off[0] == 0 and not self.no_formatting_yet:
240+
while not self.result.endswith("\n\n\n"):
241+
self.result += "\n"
242+
# When fmt:off[next] is inside a Python block (e.g. `if 1:`), the
243+
# directive ends up as a lagging_comment after flushing that block.
244+
is_nested_next = self.fmt_off[1] == "next"
245+
else:
246+
is_nested_next = False
247+
if self.lagging_comments:
248+
# For nested fmt:off[next], add the same \n separator that
249+
# process_keyword_context/add_newlines would normally provide
250+
# before the first keyword inside the Python block.
251+
if is_nested_next and not self.no_formatting_yet:
252+
self.result += "\n"
253+
self.result += self.lagging_comments
254+
self.lagging_comments = ""
255+
self.no_formatting_yet = not is_nested_next
256+
if self.fmt_off_preceded_by_blank_line:
257+
if self.result and not self.result.endswith("\n\n"):
258+
self.result += "\n"
259+
self.fmt_off_preceded_by_blank_line = False
260+
self.result += verbatim
261+
self.last_recognised_keyword = ""
262+
263+
def flush_sort_signal(self, verbatim):
264+
"""
265+
If "fmt: on sort" directive is in the keyword syntax, e.g.:
266+
267+
rule:
268+
directive1: ...
269+
# fmt: off[sort]
270+
directive2: ...
271+
# fmt: on[sort] <-
272+
# other comments
273+
directive3: ...
274+
275+
the "other comments" should be kept with directive3.
276+
This function is called when "fmt: on[sort]" reached,
277+
and it flushes the pending comments into self.result.
278+
"""
279+
if self.keywords:
280+
pending = ""
281+
for keyword in self.keyword_spec:
282+
pending += self.keywords.pop(keyword, "")
283+
self.previous_result += pending
284+
self.previous_result += self.result + verbatim
285+
self.result = ""
286+
self.last_recognised_keyword = ""
198287

199288
def run_black_format_str(
200289
self,
@@ -216,7 +305,6 @@ def run_black_format_str(
216305
and len(string.strip().splitlines()) > 1
217306
and not no_nesting
218307
)
219-
220308
if artificial_nest:
221309
string = f"if x:\n{textwrap.indent(string, TAB)}"
222310

@@ -473,6 +561,10 @@ def add_newlines(
473561
if comment_matches > 0:
474562
self.lagging_comments = "\n".join(all_lines[comment_break:]) + "\n"
475563
if final_flush:
564+
# Preserve one intentional blank line before trailing
565+
# comments at EOF (e.g. indented # fmt-like comments).
566+
if comment_break > 0 and all_lines[comment_break - 1] == "":
567+
self.result += "\n"
476568
self.result += self.lagging_comments
477569
else:
478570
self.result += formatted_string

0 commit comments

Comments
 (0)