snakefmt 2.0.0__tar.gz → 2.0.2__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.
Files changed (53) hide show
  1. {snakefmt-2.0.0 → snakefmt-2.0.2}/CHANGELOG.md +19 -0
  2. {snakefmt-2.0.0 → snakefmt-2.0.2}/PKG-INFO +2 -4
  3. {snakefmt-2.0.0 → snakefmt-2.0.2}/README.md +1 -3
  4. {snakefmt-2.0.0 → snakefmt-2.0.2}/pyproject.toml +1 -1
  5. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/formatter.py +33 -1
  6. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/shell_formatter.py +61 -14
  7. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_formatter.py +258 -96
  8. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_shell_formatter.py +128 -0
  9. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_snakefmt.py +1 -1
  10. {snakefmt-2.0.0 → snakefmt-2.0.2}/uv.lock +1 -1
  11. {snakefmt-2.0.0 → snakefmt-2.0.2}/.flake8 +0 -0
  12. {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/CODEOWNERS +0 -0
  13. {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/workflows/ci.yaml +0 -0
  14. {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/workflows/conventional-prs.yaml +0 -0
  15. {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/workflows/release-please.yaml +0 -0
  16. {snakefmt-2.0.0 → snakefmt-2.0.2}/.gitignore +0 -0
  17. {snakefmt-2.0.0 → snakefmt-2.0.2}/.pre-commit-hooks.yaml +0 -0
  18. {snakefmt-2.0.0 → snakefmt-2.0.2}/CITATION.cff +0 -0
  19. {snakefmt-2.0.0 → snakefmt-2.0.2}/CONTRIBUTING.md +0 -0
  20. {snakefmt-2.0.0 → snakefmt-2.0.2}/LICENSE +0 -0
  21. {snakefmt-2.0.0 → snakefmt-2.0.2}/Makefile +0 -0
  22. {snakefmt-2.0.0 → snakefmt-2.0.2}/codecov.yml +0 -0
  23. {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/adr/0001-shell-formatter-distribution.md +0 -0
  24. {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/adr/README.md +0 -0
  25. {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/editor_integration.md +0 -0
  26. {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/plans/pr-295-heredoc-masking.md +0 -0
  27. {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/plans/pr-295-review-response.md +0 -0
  28. {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/plans/shell-formatting-trailing-newline-bugfix.md +0 -0
  29. {snakefmt-2.0.0 → snakefmt-2.0.2}/lua/snakefmt/config.lua +0 -0
  30. {snakefmt-2.0.0 → snakefmt-2.0.2}/lua/snakefmt/init.lua +0 -0
  31. {snakefmt-2.0.0 → snakefmt-2.0.2}/lua/snakefmt/paths.lua +0 -0
  32. {snakefmt-2.0.0 → snakefmt-2.0.2}/plugin/snakefmt.vim +0 -0
  33. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/__init__.py +0 -0
  34. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/__main__.py +0 -0
  35. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/config.py +0 -0
  36. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/diff.py +0 -0
  37. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/exceptions.py +0 -0
  38. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/logging.py +0 -0
  39. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/parser/grammar.py +0 -0
  40. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/parser/parser.py +0 -0
  41. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/parser/syntax.py +0 -0
  42. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/snakefmt.py +0 -0
  43. {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/types.py +0 -0
  44. {snakefmt-2.0.0 → snakefmt-2.0.2}/test.config.toml +0 -0
  45. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/__init__.py +0 -0
  46. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/conftest.py +0 -0
  47. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_config.py +0 -0
  48. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_diff.py +0 -0
  49. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_grammar.py +0 -0
  50. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_logging.py +0 -0
  51. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_main.py +0 -0
  52. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_parser.py +0 -0
  53. {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_v8_v9_features.py +0 -0
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [2.0.2](https://github.com/snakemake/snakefmt/compare/v2.0.1...v2.0.2) (2026-06-11)
10
+
11
+
12
+ ### Bug Fixes
13
+
14
+ * preserve multiline docstring indentation under rules/checkpoints ([#304](https://github.com/snakemake/snakefmt/issues/304)) ([6fd0a5c](https://github.com/snakemake/snakefmt/commit/6fd0a5c563571b247585129a950c167cc6cc766d))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * replace PyPI badges with personalized downloads badge ([#301](https://github.com/snakemake/snakefmt/issues/301)) ([e034b36](https://github.com/snakemake/snakefmt/commit/e034b36601c73d57582f1f86b2e5a62b40ffad06))
20
+
21
+ ## [2.0.1](https://github.com/snakemake/snakefmt/compare/v2.0.0...v2.0.1) (2026-05-27)
22
+
23
+
24
+ ### Bug Fixes
25
+
26
+ * prevent repeated indentation of multiline shell strings ([#299](https://github.com/snakemake/snakefmt/issues/299)) ([7b0d97f](https://github.com/snakemake/snakefmt/commit/7b0d97fa0a2d85d0d1d09bc17e58047366dad1c0))
27
+
9
28
  ## [2.0.0](https://github.com/snakemake/snakefmt/compare/v1.1.0...v2.0.0) (2026-05-22)
10
29
 
11
30
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snakefmt
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: The uncompromising Snakemake code formatter
5
5
  Project-URL: Homepage, https://github.com/snakemake/snakefmt
6
6
  Project-URL: Repository, https://github.com/snakemake/snakefmt
@@ -96,10 +96,8 @@ design and specifications of [Black][black].
96
96
 
97
97
  ### PyPi
98
98
 
99
-
100
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/snakefmt)
101
99
  ![PyPI - Version](https://img.shields.io/pypi/v/snakefmt)
102
- ![PyPI - Downloads](https://img.shields.io/pypi/dm/snakefmt)
100
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/snakefmt?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/snakefmt)
103
101
 
104
102
  ```sh
105
103
  pip install snakefmt
@@ -77,10 +77,8 @@ design and specifications of [Black][black].
77
77
 
78
78
  ### PyPi
79
79
 
80
-
81
- ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/snakefmt)
82
80
  ![PyPI - Version](https://img.shields.io/pypi/v/snakefmt)
83
- ![PyPI - Downloads](https://img.shields.io/pypi/dm/snakefmt)
81
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/snakefmt?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/snakefmt)
84
82
 
85
83
  ```sh
86
84
  pip install snakefmt
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "snakefmt"
3
- version = "2.0.0"
3
+ version = "2.0.2"
4
4
  description = "The uncompromising Snakemake code formatter"
5
5
  authors = [
6
6
  { name = "Michael Hall", email = "mhaldini@icloud.com" },
@@ -55,6 +55,31 @@ def index_of_first_docstring(s: str) -> Optional[int]:
55
55
  return match.end(1) - 1
56
56
 
57
57
 
58
+ def is_docstring(string: str) -> bool:
59
+ """
60
+ Returns True if `string` is a single docstring, optionally followed only by
61
+ comments (ignoring surrounding whitespace). These are the rule/checkpoint
62
+ buffers that Black mis-handles as module-level docstrings (see issue #303).
63
+ """
64
+ stripped = string.strip()
65
+ match = docstring_matcher.match(stripped)
66
+ if match is None:
67
+ return False
68
+ remainder = stripped[match.end(1) :].strip()
69
+ return remainder == "" or is_all_comments(remainder)
70
+
71
+
72
+ def indent_docstring(docstring: str, target_indent: int) -> str:
73
+ """
74
+ Re-indents a Black-normalised rule/checkpoint docstring to `target_indent`.
75
+
76
+ Black treats a rule/checkpoint docstring as a module-level docstring and so
77
+ flattens it to column 0. We restore the keyword indentation on every line
78
+ here, leaving blank lines empty so they don't gain trailing whitespace.
79
+ """
80
+ return textwrap.indent(docstring, TAB * target_indent)
81
+
82
+
58
83
  class Formatter(Parser):
59
84
  def __init__(
60
85
  self,
@@ -104,7 +129,14 @@ class Formatter(Parser):
104
129
  if not from_python:
105
130
  formatted = self.run_black_format_str(self.buffer, self.block_indent)
106
131
  if self.keyword_indent > 0:
107
- formatted = self.align_strings(formatted, self.keyword_indent)
132
+ if not self.syntax.accepts_python_code and is_docstring(self.buffer):
133
+ # Black normalises rule/checkpoint docstrings but flattens them
134
+ # to column 0 (it treats them as module-level docstrings).
135
+ # align_strings cannot re-indent the inner lines of a multiline
136
+ # string, so indent the whole docstring directly. See issue #303.
137
+ formatted = indent_docstring(formatted, self.keyword_indent)
138
+ else:
139
+ formatted = self.align_strings(formatted, self.keyword_indent)
108
140
  else:
109
141
  # Invalid python syntax, eg lone 'else:' between two rules, can occur.
110
142
  # Below constructs valid code statements and formats them.
@@ -157,26 +157,73 @@ _TRIPLE_QUOTE_RE = re.compile(r"""^([a-zA-Z]*)("{3}|'{3})([\s\S]*?)(\2)$""")
157
157
 
158
158
 
159
159
  def _indent_preserving_heredocs(text: str, indent: str) -> str:
160
- """Indent each line by `indent`, skipping heredoc body and terminator lines.
160
+ """Indent each line by `indent`, skipping heredoc bodies, heredoc terminator
161
+ lines, and multi-line shell string literals (single/double quotes or backticks).
161
162
 
162
- shfmt does not reformat heredoc bodies. This function mirrors that — it adds
163
- the target indent to command lines but leaves heredoc content untouched so that
164
- terminator words remain at column 0 (required by bash for `<<EOF`) or with only
165
- leading tabs (for `<<-EOF`).
163
+ shfmt does not reformat heredoc bodies or re-indent multiline string literal
164
+ bodies. This function mirrors that — it adds the target indent to outer command
165
+ lines but leaves heredoc and multiline string content untouched.
166
166
  """
167
167
  out_lines: list[str] = []
168
- in_heredoc: str | None = None
168
+ state: str | None = None # Can be: None, "'", '"', '`', 'HEREDOC'
169
+ heredoc_delim: str | None = None
170
+
169
171
  for line in text.splitlines(keepends=True):
170
172
  stripped = line.rstrip("\n")
171
- if in_heredoc is not None:
173
+
174
+ # Do not indent lines starting inside a multiline string or heredoc.
175
+ should_indent = state is None
176
+
177
+ if state == "HEREDOC":
178
+ if stripped == heredoc_delim or stripped.lstrip("\t") == heredoc_delim:
179
+ state = None
180
+ heredoc_delim = None
181
+ else:
182
+ # Scan character by character to track quote/backtick state transitions
183
+ i = 0
184
+ n = len(line)
185
+ while i < n:
186
+ char = line[i]
187
+ if state == "'":
188
+ if char == "'":
189
+ state = None
190
+ elif state == '"':
191
+ if char == "\\":
192
+ i += 1 # Skip escaped character inside double quotes
193
+ elif char == '"':
194
+ state = None
195
+ elif state == "`":
196
+ if char == "\\":
197
+ i += 1 # Skip escaped character inside backticks
198
+ elif char == "`":
199
+ state = None
200
+ else: # state is None
201
+ if char == "\\":
202
+ i += 1 # Skip escaped character in normal code
203
+ elif char == "#":
204
+ # Comments can only start at the beginning of a word
205
+ if i == 0 or line[i - 1].isspace() or line[i - 1] in ";|&()":
206
+ break # Rest of line is comment: ignore
207
+ elif char == "'":
208
+ state = "'"
209
+ elif char == '"':
210
+ state = '"'
211
+ elif char == "`":
212
+ state = "`"
213
+ i += 1
214
+
215
+ # If we end the line in state None, check if a new heredoc starts
216
+ if state is None:
217
+ m = _HEREDOC_START.search(line)
218
+ if m:
219
+ state = "HEREDOC"
220
+ heredoc_delim = m.group(1)
221
+
222
+ if should_indent and line.strip():
223
+ out_lines.append(indent + line)
224
+ else:
172
225
  out_lines.append(line)
173
- if stripped == in_heredoc or stripped.lstrip("\t") == in_heredoc:
174
- in_heredoc = None
175
- continue
176
- out_lines.append(indent + line if line.strip() else line)
177
- m = _HEREDOC_START.search(line)
178
- if m:
179
- in_heredoc = m.group(1)
226
+
180
227
  return "".join(out_lines)
181
228
 
182
229
 
@@ -29,11 +29,11 @@ def test_emptyInput_emptyOutput():
29
29
 
30
30
  class TestSimpleParamFormatting:
31
31
  def test_simple_rule_one_input(self):
32
- stream = "rule a:\n" f'{TAB * 1}input: "foo.txt"'
32
+ stream = f'rule a:\n{TAB * 1}input: "foo.txt"'
33
33
  formatter = setup_formatter(stream)
34
34
 
35
35
  actual = formatter.get_formatted()
36
- expected = "rule a:\n" f"{TAB * 1}input:\n" f'{TAB * 2}"foo.txt",\n'
36
+ expected = f'rule a:\n{TAB * 1}input:\n{TAB * 2}"foo.txt",\n'
37
37
 
38
38
  assert actual == expected
39
39
 
@@ -41,7 +41,7 @@ class TestSimpleParamFormatting:
41
41
  """
42
42
  Keywords that expect a single parameter do not have newline + indent
43
43
  """
44
- formatter = setup_formatter("configfile: \n" f'{TAB * 1}"foo.yaml"')
44
+ formatter = setup_formatter(f'configfile: \n{TAB * 1}"foo.yaml"')
45
45
 
46
46
  actual = formatter.get_formatted()
47
47
  expected = 'configfile: "foo.yaml"\n'
@@ -173,7 +173,7 @@ use rule * from other_workflow exclude ruleC, foo as other_*
173
173
  assert formatter.get_formatted() == snakecode
174
174
 
175
175
  def test_use_rule_no_with_two_line_indented(self):
176
- snakecode = 'include: "file.txt"\n\n\n' "use rule * from module as module_*\n"
176
+ snakecode = 'include: "file.txt"\n\n\nuse rule * from module as module_*\n'
177
177
  formatter = setup_formatter(snakecode)
178
178
  assert formatter.get_formatted() == snakecode
179
179
 
@@ -187,9 +187,7 @@ use rule * from other_workflow exclude ruleC, foo as other_*
187
187
 
188
188
  def test_use_rule_newline_spacing(self):
189
189
  snakecode = (
190
- "use rule * from module as module_*\n\n\n"
191
- "rule baz:\n"
192
- f"{TAB * 1}threads: 4\n"
190
+ f"use rule * from module as module_*\n\n\nrule baz:\n{TAB * 1}threads: 4\n"
193
191
  )
194
192
  formatter = setup_formatter(snakecode)
195
193
  assert formatter.get_formatted() == snakecode
@@ -401,7 +399,7 @@ class TestSimplePythonFormatting:
401
399
  mock_method.assert_called_once()
402
400
 
403
401
  def test_python_code_with_multi_indent_passes(self):
404
- python_code = "if p:\n" f"{TAB * 1}for elem in p:\n" f"{TAB * 2}dothing(elem)\n"
402
+ python_code = f"if p:\n{TAB * 1}for elem in p:\n{TAB * 2}dothing(elem)\n"
405
403
  # test black gets called
406
404
  with mock.patch(
407
405
  "snakefmt.formatter.Formatter.run_black_format_str",
@@ -437,7 +435,7 @@ class TestSimplePythonFormatting:
437
435
  assert formatter.get_formatted() == snake_code
438
436
 
439
437
  def test_line_wrapped_python_code_outside_rule(self):
440
- content = "list_of_lots_of_things = [1, 2, 3, 4, 5, 6]\n" "include: snakefile"
438
+ content = "list_of_lots_of_things = [1, 2, 3, 4, 5, 6]\ninclude: snakefile"
441
439
  line_length = 30
442
440
  formatter = setup_formatter(content, line_length=line_length)
443
441
 
@@ -453,9 +451,7 @@ class TestSimplePythonFormatting:
453
451
 
454
452
  def test_line_wrapped_python_code_inside_rule(self):
455
453
  content = (
456
- f"rule a:\n"
457
- f"{TAB}input:\n"
458
- f"{TAB*2}list_of_lots_of_things = [1, 2, 3, 4, 5]"
454
+ f"rule a:\n{TAB}input:\n{TAB * 2}list_of_lots_of_things = [1, 2, 3, 4, 5]"
459
455
  )
460
456
  line_length = 30
461
457
  formatter = setup_formatter(content, line_length=line_length)
@@ -463,10 +459,10 @@ class TestSimplePythonFormatting:
463
459
  actual = formatter.get_formatted()
464
460
  expected = (
465
461
  "rule a:\n"
466
- f"{TAB*1}input:\n"
467
- f"{TAB*2}list_of_lots_of_things=[\n"
468
- f"{TAB*3}1,\n{TAB*3}2,\n{TAB*3}3,\n{TAB*3}4,\n{TAB*3}5,\n"
469
- f"{TAB*2}],\n"
462
+ f"{TAB * 1}input:\n"
463
+ f"{TAB * 2}list_of_lots_of_things=[\n"
464
+ f"{TAB * 3}1,\n{TAB * 3}2,\n{TAB * 3}3,\n{TAB * 3}4,\n{TAB * 3}5,\n"
465
+ f"{TAB * 2}],\n"
470
466
  )
471
467
 
472
468
  assert actual == expected
@@ -499,11 +495,11 @@ class TestSimplePythonFormatting:
499
495
  """Issue 262. Make sure no spacing added between !r"""
500
496
  snakecode = (
501
497
  'if config.get("treefix_root", "").startswith(ROOT_FLAG):\n'
502
- f"{TAB*1}print(\n"
503
- f"{TAB*2}f\"Removing the flag {{ROOT_FLAT!r}} from config['treefix_root'] ; \"\n" # noqa: E501
504
- f'{TAB*2}f"consider updating the config param in the config file.",\n'
505
- f"{TAB*2}file=sys.stderr,\n"
506
- f"{TAB*1})\n"
498
+ f"{TAB * 1}print(\n"
499
+ f"{TAB * 2}f\"Removing the flag {{ROOT_FLAT!r}} from config['treefix_root'] ; \"\n" # noqa: E501
500
+ f'{TAB * 2}f"consider updating the config param in the config file.",\n'
501
+ f"{TAB * 2}file=sys.stderr,\n"
502
+ f"{TAB * 1})\n"
507
503
  )
508
504
  formatter = setup_formatter(snakecode)
509
505
 
@@ -554,7 +550,7 @@ class TestComplexPythonFormatting:
554
550
  assert formatter.get_formatted() == expected
555
551
 
556
552
  def test_python_code_after_nested_snakecode_gets_formatted(self):
557
- snakecode = "if condition:\n" f'{TAB * 1}include: "a"\n' "b=2\n"
553
+ snakecode = f'if condition:\n{TAB * 1}include: "a"\nb=2\n'
558
554
  with mock.patch(
559
555
  "snakefmt.formatter.Formatter.run_black_format_str", spec=True
560
556
  ) as mock_m:
@@ -576,7 +572,7 @@ class TestComplexPythonFormatting:
576
572
  assert formatter.get_formatted() == expected
577
573
 
578
574
  def test_python_code_before_nested_snakecode_gets_formatted(self):
579
- snakecode = "b=2\n" "if condition:\n" f'{TAB * 1}include: "a"\n'
575
+ snakecode = f'b=2\nif condition:\n{TAB * 1}include: "a"\n'
580
576
  with mock.patch(
581
577
  "snakefmt.formatter.Formatter.run_black_format_str", spec=True
582
578
  ) as mock_m:
@@ -585,7 +581,7 @@ class TestComplexPythonFormatting:
585
581
  assert mock_m.call_count == 3
586
582
 
587
583
  formatter = setup_formatter(snakecode)
588
- expected = "b = 2\n" "if condition:\n\n" f'{TAB * 1}include: "a"\n'
584
+ expected = f'b = 2\nif condition:\n\n{TAB * 1}include: "a"\n'
589
585
  assert formatter.get_formatted() == expected
590
586
 
591
587
  def test_pythoncode_parser_based_formatting_before_snakecode(self):
@@ -826,9 +822,7 @@ class TestStringFormatting:
826
822
  assert formatter.get_formatted() == expected
827
823
 
828
824
  def test_keyword_with_tpq_inside_expression_left_alone(self):
829
- snakecode = (
830
- "rule test:\n" f"{TAB * 1}run:\n" f'{TAB * 2}shell(f"""shell stuff""")\n'
831
- )
825
+ snakecode = f'rule test:\n{TAB * 1}run:\n{TAB * 2}shell(f"""shell stuff""")\n'
832
826
  formatter = setup_formatter(snakecode)
833
827
  assert formatter.get_formatted() == snakecode
834
828
 
@@ -984,6 +978,141 @@ rule a:
984
978
  '''
985
979
  assert formatter.get_formatted() == expected
986
980
 
981
+ def test_multiline_docstring_inside_rule_preserves_indentation(self):
982
+ """https://github.com/snakemake/snakefmt/issues/303"""
983
+ snakecode = (
984
+ "rule test:\n"
985
+ f'{TAB * 1}"""\n'
986
+ f"{TAB * 1}Comment\n"
987
+ f'{TAB * 1}"""\n'
988
+ f"{TAB * 1}input:\n"
989
+ f'{TAB * 2}"test.txt",\n'
990
+ )
991
+ formatter = setup_formatter(snakecode)
992
+ assert formatter.get_formatted() == snakecode
993
+
994
+ def test_multiline_docstring_inside_checkpoint_preserves_indentation(self):
995
+ """https://github.com/snakemake/snakefmt/issues/303"""
996
+ snakecode = (
997
+ "checkpoint test:\n"
998
+ f'{TAB * 1}"""\n'
999
+ f"{TAB * 1}Checkpoint comment\n"
1000
+ f'{TAB * 1}"""\n'
1001
+ f"{TAB * 1}input:\n"
1002
+ f'{TAB * 2}"test.txt",\n'
1003
+ )
1004
+ formatter = setup_formatter(snakecode)
1005
+ assert formatter.get_formatted() == snakecode
1006
+
1007
+ def test_single_line_docstring_inside_rule_remains_intact(self):
1008
+ """https://github.com/snakemake/snakefmt/issues/303"""
1009
+ snakecode = (
1010
+ "rule test:\n"
1011
+ f'{TAB * 1}"""Comment"""\n'
1012
+ f"{TAB * 1}input:\n"
1013
+ f'{TAB * 2}"test.txt",\n'
1014
+ )
1015
+ formatter = setup_formatter(snakecode)
1016
+ assert formatter.get_formatted() == snakecode
1017
+
1018
+ def test_multiline_docstring_with_blank_line_inside_rule(self):
1019
+ """A blank line inside a docstring stays empty (no trailing whitespace)"""
1020
+ snakecode = (
1021
+ "rule test:\n"
1022
+ f'{TAB * 1}"""\n'
1023
+ f"{TAB * 1}First paragraph.\n"
1024
+ "\n"
1025
+ f"{TAB * 1}Second paragraph.\n"
1026
+ f'{TAB * 1}"""\n'
1027
+ f"{TAB * 1}input:\n"
1028
+ f'{TAB * 2}"test.txt",\n'
1029
+ )
1030
+ # The expected output equals the input: the blank line stays a bare "\n".
1031
+ formatter = setup_formatter(snakecode)
1032
+ assert formatter.get_formatted() == snakecode
1033
+
1034
+ def test_multiline_docstring_preserves_relative_indentation(self):
1035
+ """Indented lines within a docstring keep their relative indentation"""
1036
+ snakecode = (
1037
+ "rule test:\n"
1038
+ f'{TAB * 1}"""\n'
1039
+ f"{TAB * 1}Summary line.\n"
1040
+ f"{TAB * 2}Indented detail.\n"
1041
+ f'{TAB * 1}"""\n'
1042
+ f"{TAB * 1}input:\n"
1043
+ f'{TAB * 2}"test.txt",\n'
1044
+ )
1045
+ formatter = setup_formatter(snakecode)
1046
+ assert formatter.get_formatted() == snakecode
1047
+
1048
+ def test_multiline_docstring_followed_by_comment_inside_rule(self):
1049
+ """A docstring followed by a comment is still indented under the rule"""
1050
+ snakecode = (
1051
+ "rule test:\n"
1052
+ f'{TAB * 1}"""\n'
1053
+ f"{TAB * 1}Comment\n"
1054
+ f'{TAB * 1}"""\n'
1055
+ f"{TAB * 1}# trailing comment\n"
1056
+ f"{TAB * 1}input:\n"
1057
+ f'{TAB * 2}"test.txt",\n'
1058
+ )
1059
+ # Black inserts a blank line between the docstring and the comment.
1060
+ expected = (
1061
+ "rule test:\n"
1062
+ f'{TAB * 1}"""\n'
1063
+ f"{TAB * 1}Comment\n"
1064
+ f'{TAB * 1}"""\n'
1065
+ "\n"
1066
+ f"{TAB * 1}# trailing comment\n"
1067
+ f"{TAB * 1}input:\n"
1068
+ f'{TAB * 2}"test.txt",\n'
1069
+ )
1070
+ formatter = setup_formatter(snakecode)
1071
+ assert formatter.get_formatted() == expected
1072
+
1073
+ def test_collapsing_docstring_inside_rule_keeps_black_normalisation(self):
1074
+ """Black still collapses a single-line docstring; indentation is preserved"""
1075
+ snakecode = (
1076
+ "rule a:\n"
1077
+ f'{TAB * 1}"""The rule a\n'
1078
+ f'{TAB * 1}"""\n'
1079
+ f"{TAB * 1}input:\n"
1080
+ f'{TAB * 2}"test.txt",\n'
1081
+ )
1082
+ expected = (
1083
+ "rule a:\n"
1084
+ f'{TAB * 1}"""The rule a"""\n'
1085
+ f"{TAB * 1}input:\n"
1086
+ f'{TAB * 2}"test.txt",\n'
1087
+ )
1088
+ formatter = setup_formatter(snakecode)
1089
+ assert formatter.get_formatted() == expected
1090
+
1091
+ def test_multiline_docstring_inside_nested_rule_preserves_indentation(self):
1092
+ """A docstring in a rule nested under an if-block keeps its deeper indent"""
1093
+ snakecode = (
1094
+ "if True:\n"
1095
+ f"{TAB * 1}rule test:\n"
1096
+ f'{TAB * 2}"""\n'
1097
+ f"{TAB * 2}Nested comment\n"
1098
+ f'{TAB * 2}"""\n'
1099
+ f"{TAB * 2}input:\n"
1100
+ f'{TAB * 3}"test.txt",\n'
1101
+ )
1102
+ # snakefmt inserts a blank line after the `if True:` header.
1103
+ expected = (
1104
+ "if True:\n"
1105
+ "\n"
1106
+ f"{TAB * 1}rule test:\n"
1107
+ f'{TAB * 2}"""\n'
1108
+ f"{TAB * 2}Nested comment\n"
1109
+ f'{TAB * 2}"""\n'
1110
+ f"{TAB * 2}input:\n"
1111
+ f'{TAB * 3}"test.txt",\n'
1112
+ )
1113
+ formatter = setup_formatter(snakecode)
1114
+ assert formatter.get_formatted() == expected
1115
+
987
1116
  def test_shell_mixed_string_types(self):
988
1117
  """https://github.com/snakemake/snakefmt/issues/129"""
989
1118
  snakecode = (
@@ -1095,11 +1224,7 @@ rule a:
1095
1224
  mock_format.side_effect = side_effect
1096
1225
 
1097
1226
  snakecode = (
1098
- "rule a:\n"
1099
- f"{TAB * 1}shell:\n"
1100
- f'{TAB * 2}"""\n'
1101
- f"{TAB * 2}cmd\n"
1102
- f'{TAB * 2}"""\n'
1227
+ f'rule a:\n{TAB * 1}shell:\n{TAB * 2}"""\n{TAB * 2}cmd\n{TAB * 2}"""\n'
1103
1228
  )
1104
1229
  formatter = setup_formatter(snakecode)
1105
1230
  assert formatter.get_formatted() == snakecode
@@ -1131,11 +1256,9 @@ class TestReformatting_SMK_BREAK:
1131
1256
  def test_key_value_parameter_repositioning(self):
1132
1257
  """Key/val params can occur before positional params"""
1133
1258
  formatter = setup_formatter(
1134
- f"rule a:\n" f"{TAB * 1}input:\n" f'{TAB * 2}a="b",\n' f'{TAB * 2}"c"\n'
1135
- )
1136
- expected = (
1137
- f"rule a:\n" f"{TAB * 1}input:\n" f'{TAB * 2}"c",\n' f'{TAB * 2}a="b",\n'
1259
+ f'rule a:\n{TAB * 1}input:\n{TAB * 2}a="b",\n{TAB * 2}"c"\n'
1138
1260
  )
1261
+ expected = f'rule a:\n{TAB * 1}input:\n{TAB * 2}"c",\n{TAB * 2}a="b",\n'
1139
1262
  assert formatter.get_formatted() == expected
1140
1263
 
1141
1264
 
@@ -1147,7 +1270,7 @@ class TestCommentTreatment:
1147
1270
  assert formatter.get_formatted() == expected
1148
1271
 
1149
1272
  def test_comment_after_keyword_kept(self):
1150
- snakecode = "rule a: # A comment \n" f"{TAB * 1}threads: 4\n"
1273
+ snakecode = f"rule a: # A comment \n{TAB * 1}threads: 4\n"
1151
1274
  formatter = setup_formatter(snakecode)
1152
1275
  assert formatter.get_formatted() == snakecode
1153
1276
 
@@ -1179,9 +1302,7 @@ class TestCommentTreatment:
1179
1302
  assert formatter.get_formatted() == expected
1180
1303
 
1181
1304
  def test_comment_outside_keyword_context_stays_untouched(self):
1182
- snakecode = (
1183
- f"rule a:\n" f"{TAB * 1}run:\n" f"{TAB * 2}f()\n\n\n" f"# A comment\n"
1184
- )
1305
+ snakecode = f"rule a:\n{TAB * 1}run:\n{TAB * 2}f()\n\n\n# A comment\n"
1185
1306
  formatter = setup_formatter(snakecode)
1186
1307
  assert formatter.get_formatted() == snakecode
1187
1308
 
@@ -1326,10 +1447,7 @@ class TestCommentTreatment:
1326
1447
  def test_two_comments_in_global_context(self):
1327
1448
  """https://github.com/snakemake/snakefmt/issues/169#issuecomment-1365540999"""
1328
1449
  snakecode = (
1329
- 'configfile: "config.yaml"\n\n\n'
1330
- "# AAA\n"
1331
- "# BBB\n\n"
1332
- 'BATCH = "20220202"\n'
1450
+ 'configfile: "config.yaml"\n\n\n# AAA\n# BBB\n\nBATCH = "20220202"\n'
1333
1451
  )
1334
1452
  formatter = setup_formatter(snakecode)
1335
1453
  assert formatter.get_formatted() == snakecode
@@ -1349,7 +1467,7 @@ class TestCommentTreatment:
1349
1467
 
1350
1468
  class TestNewlineSpacing:
1351
1469
  def test_parameter_keyword_spacing_above(self):
1352
- formatter = setup_formatter("b = 2\n" 'configfile: "config.yaml"')
1470
+ formatter = setup_formatter('b = 2\nconfigfile: "config.yaml"')
1353
1471
 
1354
1472
  actual = formatter.get_formatted()
1355
1473
  expected = 'b = 2\n\n\nconfigfile: "config.yaml"\n'
@@ -1381,7 +1499,7 @@ class TestNewlineSpacing:
1381
1499
  assert formatter.get_formatted() == replaced
1382
1500
 
1383
1501
  def test_repeated_parameter_keyword_comment_in_between_no_spacing(self):
1384
- snakecode = 'include: "a"\n# A comment\n' ' # c2\ninclude: "b"\n'
1502
+ snakecode = 'include: "a"\n# A comment\n # c2\ninclude: "b"\n'
1385
1503
  expected = 'include: "a"\n# A comment\n# c2\ninclude: "b"\n'
1386
1504
  assert setup_formatter(snakecode).get_formatted() == expected
1387
1505
 
@@ -1454,35 +1572,23 @@ below_rule = "2spaces"
1454
1572
  assert formatter.get_formatted() == expected
1455
1573
 
1456
1574
  def test_initial_comment_does_not_trigger_spacing(self):
1457
- snakecode = (
1458
- f"# load config\n" f"rule all:\n" f"{TAB * 1}input:\n" f"{TAB * 2}files,\n"
1459
- )
1575
+ snakecode = f"# load config\nrule all:\n{TAB * 1}input:\n{TAB * 2}files,\n"
1460
1576
 
1461
1577
  formatter = setup_formatter(snakecode)
1462
1578
  assert formatter.get_formatted() == snakecode
1463
1579
 
1464
1580
  def test_comment_sticks_to_rule(self):
1465
1581
  snakecode = (
1466
- "def p():\n"
1467
- f"{TAB * 1}pass\n"
1468
- f"#My rule a\n"
1469
- f"rule a:\n"
1470
- f"{TAB * 1}threads: 1\n"
1582
+ f"def p():\n{TAB * 1}pass\n#My rule a\nrule a:\n{TAB * 1}threads: 1\n"
1471
1583
  )
1472
1584
  formatter = setup_formatter(snakecode)
1473
1585
  expected = (
1474
- "def p():\n"
1475
- f"{TAB * 1}pass\n\n\n"
1476
- f"# My rule a\n"
1477
- f"rule a:\n"
1478
- f"{TAB * 1}threads: 1\n"
1586
+ f"def p():\n{TAB * 1}pass\n\n\n# My rule a\nrule a:\n{TAB * 1}threads: 1\n"
1479
1587
  )
1480
1588
  assert formatter.get_formatted() == expected
1481
1589
 
1482
1590
  def test_keyword_disjoint_comment_stays_keyword_disjoint(self):
1483
- snakecode = (
1484
- "def p():\n" f"{TAB * 1}pass\n" f"#A lone comment\n\n" f'include: "a"\n'
1485
- )
1591
+ snakecode = f'def p():\n{TAB * 1}pass\n#A lone comment\n\ninclude: "a"\n'
1486
1592
  formatter = setup_formatter(snakecode)
1487
1593
  expected = (
1488
1594
  "def p():\n"
@@ -1498,8 +1604,8 @@ below_rule = "2spaces"
1498
1604
  assert setup_formatter(snakecode).get_formatted() == expected
1499
1605
 
1500
1606
  def test_comment_inside_python_code_sticks_to_rule(self):
1501
- snakecode = f"if p:\n" f"{TAB * 1}# A comment\n" f'{TAB * 1}include: "a"\n'
1502
- expected = f"if p:\n\n" f"{TAB * 1}# A comment\n" f'{TAB * 1}include: "a"\n'
1607
+ snakecode = f'if p:\n{TAB * 1}# A comment\n{TAB * 1}include: "a"\n'
1608
+ expected = f'if p:\n\n{TAB * 1}# A comment\n{TAB * 1}include: "a"\n'
1503
1609
  assert setup_formatter(snakecode).get_formatted() == expected
1504
1610
 
1505
1611
  def test_comment_below_keyword_gets_spaced(self):
@@ -1588,14 +1694,14 @@ class TestLineWrapping:
1588
1694
  """https://github.com/snakemake/snakefmt/issues/124"""
1589
1695
  snakecode = (
1590
1696
  "rule a:\n"
1591
- f"{TAB*1}output:\n"
1592
- f'{TAB*2}"foo",\n'
1593
- f"{TAB*1}params:\n"
1594
- f"{TAB*2}datasources=(\n"
1595
- f'{TAB*3}"-s {{}}".format(" ".join(config["annotations"]["dgidb"]["datasources"]))\n' # noqa: E501
1596
- f'{TAB*3}if config["annotations"]["dgidb"].get("datasources", "")\n'
1597
- f'{TAB*3}else ""\n'
1598
- f"{TAB*2}),\n"
1697
+ f"{TAB * 1}output:\n"
1698
+ f'{TAB * 2}"foo",\n'
1699
+ f"{TAB * 1}params:\n"
1700
+ f"{TAB * 2}datasources=(\n"
1701
+ f'{TAB * 3}"-s {{}}".format(" ".join(config["annotations"]["dgidb"]["datasources"]))\n' # noqa: E501
1702
+ f'{TAB * 3}if config["annotations"]["dgidb"].get("datasources", "")\n'
1703
+ f'{TAB * 3}else ""\n'
1704
+ f"{TAB * 2}),\n"
1599
1705
  )
1600
1706
  formatter = setup_formatter(snakecode)
1601
1707
 
@@ -1605,15 +1711,15 @@ class TestLineWrapping:
1605
1711
  """https://github.com/snakemake/snakefmt/issues/124#issuecomment-986845398"""
1606
1712
  snakecode = (
1607
1713
  "if True:\n\n"
1608
- f"{TAB*1}def func1():\n"
1609
- f'''{TAB*2}"""docstring"""\n'''
1610
- f"{TAB*2}pass\n\n"
1611
- f"{TAB*1}rule foo:\n"
1612
- f"{TAB*2}shell:\n"
1613
- f'{TAB*3}"echo bar"\n\n'
1614
- f"{TAB*1}def func2():\n"
1615
- f'''{TAB*2}"""this function should stay indented"""\n'''
1616
- f"{TAB*2}pass\n"
1714
+ f"{TAB * 1}def func1():\n"
1715
+ f'''{TAB * 2}"""docstring"""\n'''
1716
+ f"{TAB * 2}pass\n\n"
1717
+ f"{TAB * 1}rule foo:\n"
1718
+ f"{TAB * 2}shell:\n"
1719
+ f'{TAB * 3}"echo bar"\n\n'
1720
+ f"{TAB * 1}def func2():\n"
1721
+ f'''{TAB * 2}"""this function should stay indented"""\n'''
1722
+ f"{TAB * 2}pass\n"
1617
1723
  )
1618
1724
  formatter = setup_formatter(snakecode)
1619
1725
 
@@ -1990,7 +2096,7 @@ def test_invalid_python_error_line_number():
1990
2096
 
1991
2097
 
1992
2098
  def test_invalid_python_error_eof():
1993
- snakecode = "rule a:\n" f"{TAB * 1}run:\n" f"{TAB * 2}a = \n"
2099
+ snakecode = f"rule a:\n{TAB * 1}run:\n{TAB * 2}a = \n"
1994
2100
  with pytest.raises(InvalidPython) as excinfo:
1995
2101
  setup_formatter(snakecode).get_formatted()
1996
2102
  msg = str(excinfo.value)
@@ -2026,7 +2132,8 @@ def test_invalid_python_error_no_dedent(mock_format):
2026
2132
  from snakefmt.parser.syntax import KeywordSyntax
2027
2133
 
2028
2134
  formatter.context = Context(
2029
- None, KeywordSyntax("Global", keyword_indent=0, accepts_py=True) # type: ignore
2135
+ None,
2136
+ KeywordSyntax("Global", keyword_indent=0, accepts_py=True), # type: ignore
2030
2137
  )
2031
2138
  # Manually set last_token to something that isn't DEDENT/ENDMARKER
2032
2139
  formatter.last_token = tokenize.TokenInfo(
@@ -2046,7 +2153,7 @@ def test_invalid_python_error_no_match(mock_format):
2046
2153
  raise black.parsing.InvalidInput("Custom black error without line number")
2047
2154
 
2048
2155
  mock_format.side_effect = side_effect
2049
- snakecode = "rule a:\n" f"{TAB * 1}run:\n" f"{TAB * 2}a = 1\n"
2156
+ snakecode = f"rule a:\n{TAB * 1}run:\n{TAB * 2}a = 1\n"
2050
2157
  with pytest.raises(InvalidPython) as excinfo:
2051
2158
  setup_formatter(snakecode).get_formatted()
2052
2159
  msg = str(excinfo.value)
@@ -2067,13 +2174,7 @@ def test_multiline_fallback(mock_format):
2067
2174
 
2068
2175
  mock_format.side_effect = side_effect
2069
2176
 
2070
- snakecode = (
2071
- "rule a:\n"
2072
- f"{TAB * 1}shell:\n"
2073
- f'{TAB * 2}"""\n'
2074
- f"{TAB * 2}cmd\n"
2075
- f'{TAB * 2}"""\n'
2076
- )
2177
+ snakecode = f'rule a:\n{TAB * 1}shell:\n{TAB * 2}"""\n{TAB * 2}cmd\n{TAB * 2}"""\n'
2077
2178
  formatter = setup_formatter(snakecode)
2078
2179
  # The actual result won't be perfect because we hand-mocked Black,
2079
2180
  # but it guarantees the code path is executed.
@@ -2111,6 +2212,67 @@ def test_index_of_first_docstring_match():
2111
2212
  assert index_of_first_docstring('"""docstring"""') == 14
2112
2213
 
2113
2214
 
2215
+ class TestDocstringHelpers:
2216
+ """Unit tests for the is_docstring / indent_docstring helpers (issue #303)."""
2217
+
2218
+ @pytest.mark.parametrize(
2219
+ "string",
2220
+ [
2221
+ # Real rule/checkpoint docstring buffers reaching is_docstring():
2222
+ '"""single line"""', # single-line docstring buffer
2223
+ '"""\nmultiline\n"""', # the issue #303 multiline buffer
2224
+ 'r"""raw docstring"""', # raw docstring
2225
+ "'''single quotes'''", # single-quoted docstring
2226
+ '"""docstring"""\n# trailing comment', # docstring + comment (Hocnonsense)
2227
+ # Defensive: leading/trailing whitespace only guards the .strip().
2228
+ ' \n"""leading/trailing whitespace"""\n ',
2229
+ ],
2230
+ )
2231
+ def test_is_docstring_true(self, string):
2232
+ from snakefmt.formatter import is_docstring
2233
+
2234
+ assert is_docstring(string) is True
2235
+
2236
+ @pytest.mark.parametrize(
2237
+ "string",
2238
+ [
2239
+ # Real buffers that must NOT be treated as docstrings:
2240
+ "# just a comment", # comment-only rule buffer
2241
+ 'x = """assigned triple-quoted"""', # assignment, not a docstring
2242
+ # Defensive: these can't occur as a directive-context buffer, but the
2243
+ # helper must still reject them (empty is short-circuited earlier;
2244
+ # arbitrary Python is invalid inside a rule directive context).
2245
+ "",
2246
+ "x = 1",
2247
+ '"""docstring"""\nx = 1', # docstring followed by code
2248
+ 'x = 1\n"""docstring"""', # code followed by docstring
2249
+ ],
2250
+ )
2251
+ def test_is_docstring_false(self, string):
2252
+ from snakefmt.formatter import is_docstring
2253
+
2254
+ assert is_docstring(string) is False
2255
+
2256
+ def test_indent_docstring_single_line(self):
2257
+ from snakefmt.formatter import indent_docstring
2258
+
2259
+ assert indent_docstring('"""doc"""\n', 1) == f'{TAB * 1}"""doc"""\n'
2260
+
2261
+ def test_indent_docstring_multiline(self):
2262
+ from snakefmt.formatter import indent_docstring
2263
+
2264
+ docstring = '"""\nline\n"""\n'
2265
+ expected = f'{TAB * 2}"""\n{TAB * 2}line\n{TAB * 2}"""\n'
2266
+ assert indent_docstring(docstring, 2) == expected
2267
+
2268
+ def test_indent_docstring_leaves_blank_lines_without_trailing_whitespace(self):
2269
+ from snakefmt.formatter import indent_docstring
2270
+
2271
+ docstring = '"""\na\n\nb\n"""\n'
2272
+ expected = f'{TAB * 1}"""\n{TAB * 1}a\n\n{TAB * 1}b\n{TAB * 1}"""\n'
2273
+ assert indent_docstring(docstring, 1) == expected
2274
+
2275
+
2114
2276
  class TestFmtOffOn:
2115
2277
  """Tests for # fmt: off / # fmt: on directives."""
2116
2278
 
@@ -2210,10 +2372,10 @@ class TestFmtOffOn:
2210
2372
  )
2211
2373
  assert setup_formatter(code).get_formatted() == expected
2212
2374
  bad_indent = " "
2213
- snakecode = "rule:\n" " run:\n" + (
2375
+ snakecode = "rule:\n run:\n" + (
2214
2376
  "".join(f"{bad_indent}{i}\n" for i in code.splitlines())
2215
2377
  )
2216
- snakexpected = "rule:\n" f"{TAB * 1}run:\n" + (
2378
+ snakexpected = f"rule:\n{TAB * 1}run:\n" + (
2217
2379
  f"{TAB * 2}# ?\n"
2218
2380
  f"{TAB * 2}x = [1, 2, 3]\n"
2219
2381
  f"{TAB * 2}# fmt: off\n"
@@ -2348,7 +2510,7 @@ class TestFmtOffOn:
2348
2510
  assert formatter.get_formatted() == expected
2349
2511
 
2350
2512
  def test_fmt_off_lagging_comments(self):
2351
- expected = "if 1:\n" f"{TAB * 1}lagging_comments\n" "\n" f"{TAB * 1}# fmtany\n"
2513
+ expected = f"if 1:\n{TAB * 1}lagging_comments\n\n{TAB * 1}# fmtany\n"
2352
2514
  assert setup_formatter(expected).get_formatted() == expected
2353
2515
  expected = (
2354
2516
  "if 1:\n"
@@ -2489,8 +2651,8 @@ class TestFmtOffSort:
2489
2651
  def test_fmt_off_sort_nothing(self):
2490
2652
  code1, formatted1 = TestSortFormatting.sorting_comprehensive
2491
2653
  code3, formatted3 = TestSortFormatting.sort_inline_comments
2492
- code = code1.rstrip() + "\n" "\n" "if 1:\n" " pass\n" "\n\n" + code3
2493
- expected = formatted1 + "\n\n" "if 1:\n" f"{TAB}pass\n" "\n\n" + formatted3
2654
+ code = code1.rstrip() + "\n\nif 1:\n pass\n\n\n" + code3
2655
+ expected = formatted1 + f"\n\nif 1:\n{TAB}pass\n\n\n" + formatted3
2494
2656
  assert setup_formatter(code, sort_params=True).get_formatted() == expected
2495
2657
 
2496
2658
  def test_fmt_off_sort_between_directive(self):
@@ -271,3 +271,131 @@ class TestFormatPythonStringLiteralHeredocs:
271
271
  ' """'
272
272
  )
273
273
  assert format_python_string_literal(literal, target_indent=2) == expected
274
+
275
+
276
+ class TestFormatPythonStringLiteralMultilineStrings:
277
+ def test_multiline_single_quoted_string_idempotent(self):
278
+ # Multi-line single-quoted string inside the shell block (e.g. bash -c '...')
279
+ literal = (
280
+ '"""\n'
281
+ " squashfs-mount image.sqsh:/env -- bash -c '\n"
282
+ " source /env/bin/activate\n"
283
+ " echo hello\n"
284
+ " '\n"
285
+ ' """'
286
+ )
287
+
288
+ # First run formats the outer code and should not mangle the
289
+ # inner string's indent
290
+ f1 = format_python_string_literal(literal, target_indent=2)
291
+ # Second run should produce the exact same output (idempotency)
292
+ f2 = format_python_string_literal(f1, target_indent=2)
293
+
294
+ assert f1 == f2
295
+
296
+ def test_multiline_double_quoted_string_idempotent(self):
297
+ # Multi-line double-quoted string inside the shell block (e.g. echo "...")
298
+ literal = (
299
+ '"""\n echo "line 1\n line 2\n line 3"\n """'
300
+ )
301
+
302
+ f1 = format_python_string_literal(literal, target_indent=2)
303
+ f2 = format_python_string_literal(f1, target_indent=2)
304
+
305
+ assert f1 == f2
306
+
307
+ def test_multiline_backtick_string_idempotent(self):
308
+ # Multi-line backtick string inside the shell block
309
+ literal = (
310
+ '"""\n echo `line 1\n line 2\n line 3`\n """'
311
+ )
312
+
313
+ f1 = format_python_string_literal(literal, target_indent=2)
314
+ f2 = format_python_string_literal(f1, target_indent=2)
315
+
316
+ assert f1 == f2
317
+
318
+ def test_double_quote_escaping(self):
319
+ # \" inside double quotes
320
+ literal = '"""\n echo "line 1 \\" still inside \\" line 2"\n """'
321
+ f1 = format_python_string_literal(literal, target_indent=2)
322
+ assert '\\" still inside \\"' in f1
323
+
324
+ def test_general_escapes(self):
325
+ # \ in normal code
326
+ literal = '"""\n echo \\$foo\n """'
327
+ f1 = format_python_string_literal(literal, target_indent=2)
328
+ assert "echo \\$foo" in f1
329
+
330
+ def test_comments_diverse_boundaries(self):
331
+ # Comment at line start, comment after space
332
+ literal = (
333
+ '"""\n'
334
+ " # start comment\n"
335
+ " echo foo # inline comment\n"
336
+ ' """'
337
+ )
338
+ f1 = format_python_string_literal(literal, target_indent=2)
339
+ assert " # start comment" in f1
340
+
341
+ def test_non_triple_quoted_string_returned_unchanged(self):
342
+ literal = '"echo hello"'
343
+ assert format_python_string_literal(literal, target_indent=2) == literal
344
+
345
+ def test_newline_prepending_branches(self):
346
+ # 1. indented content is non-empty (does not start with newline)
347
+ literal1 = '"""\nls\n"""'
348
+ f1 = format_python_string_literal(literal1, target_indent=2)
349
+ assert f1.startswith('"""\n ls')
350
+
351
+ # 2. indented content is empty
352
+ literal2 = '"""\n"""'
353
+ f2 = format_python_string_literal(literal2, target_indent=2)
354
+ assert f2 == '"""\n """'
355
+
356
+
357
+ def test_indent_preserving_heredocs_direct_edge_cases():
358
+ # 1. Backtick state transition
359
+ text = "echo `line 1\nline 2\nline 3`\n"
360
+ res = _indent_preserving_heredocs(text, " ")
361
+ assert res == " echo `line 1\nline 2\nline 3`\n"
362
+
363
+ # 2. Escaped backtick inside backticks
364
+ text = "echo `line 1 \\` line 2`\n"
365
+ res = _indent_preserving_heredocs(text, " ")
366
+ assert res == " echo `line 1 \\` line 2`\n"
367
+
368
+ # 3. Escaped double quote inside double quotes
369
+ text = 'echo "line 1 \\" line 2"\n'
370
+ res = _indent_preserving_heredocs(text, " ")
371
+ assert res == ' echo "line 1 \\" line 2"\n'
372
+
373
+ # 4. Escaped backslash in normal code
374
+ text = "echo \\\\\n"
375
+ res = _indent_preserving_heredocs(text, " ")
376
+ assert res == " echo \\\\\n"
377
+
378
+ # 5. Comment boundaries after control characters and inside words (valid syntax)
379
+ text = (
380
+ "# start comment\n"
381
+ "echo bar;# control ;\n"
382
+ "echo bar&# control &\n"
383
+ "echo bar |# control |\n"
384
+ "cat\n"
385
+ "(# control (\n"
386
+ "echo inside\n"
387
+ ")# control )\n"
388
+ "echo foo#bar\n"
389
+ )
390
+ res = _indent_preserving_heredocs(text, " ")
391
+ assert res == (
392
+ " # start comment\n"
393
+ " echo bar;# control ;\n"
394
+ " echo bar&# control &\n"
395
+ " echo bar |# control |\n"
396
+ " cat\n"
397
+ " (# control (\n"
398
+ " echo inside\n"
399
+ " )# control )\n"
400
+ " echo foo#bar\n"
401
+ )
@@ -97,7 +97,7 @@ class TestCLIBasic:
97
97
  cli_runner.invoke(main, params)
98
98
  actual_stat = file.stat()
99
99
 
100
- assert actual_stat == expected_stat
100
+ assert actual_stat.st_mtime == expected_stat.st_mtime
101
101
 
102
102
 
103
103
  class TestCLICheck:
@@ -1019,7 +1019,7 @@ wheels = [
1019
1019
 
1020
1020
  [[package]]
1021
1021
  name = "snakefmt"
1022
- version = "2.0.0"
1022
+ version = "2.0.2"
1023
1023
  source = { editable = "." }
1024
1024
  dependencies = [
1025
1025
  { name = "black" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes