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.
- {snakefmt-2.0.0 → snakefmt-2.0.2}/CHANGELOG.md +19 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/PKG-INFO +2 -4
- {snakefmt-2.0.0 → snakefmt-2.0.2}/README.md +1 -3
- {snakefmt-2.0.0 → snakefmt-2.0.2}/pyproject.toml +1 -1
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/formatter.py +33 -1
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/shell_formatter.py +61 -14
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_formatter.py +258 -96
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_shell_formatter.py +128 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_snakefmt.py +1 -1
- {snakefmt-2.0.0 → snakefmt-2.0.2}/uv.lock +1 -1
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.flake8 +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/CODEOWNERS +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/workflows/ci.yaml +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/workflows/conventional-prs.yaml +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.github/workflows/release-please.yaml +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.gitignore +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/.pre-commit-hooks.yaml +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/CITATION.cff +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/CONTRIBUTING.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/LICENSE +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/Makefile +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/codecov.yml +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/adr/0001-shell-formatter-distribution.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/adr/README.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/editor_integration.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/plans/pr-295-heredoc-masking.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/plans/pr-295-review-response.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/docs/plans/shell-formatting-trailing-newline-bugfix.md +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/lua/snakefmt/config.lua +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/lua/snakefmt/init.lua +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/lua/snakefmt/paths.lua +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/plugin/snakefmt.vim +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/__init__.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/__main__.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/config.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/diff.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/exceptions.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/logging.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/parser/grammar.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/parser/parser.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/parser/syntax.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/snakefmt.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/snakefmt/types.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/test.config.toml +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/__init__.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/conftest.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_config.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_diff.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_grammar.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_logging.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_main.py +0 -0
- {snakefmt-2.0.0 → snakefmt-2.0.2}/tests/test_parser.py +0 -0
- {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.
|
|
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
|
-

|
|
101
99
|

|
|
102
|
-
](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
|
-

|
|
82
80
|

|
|
83
|
-
](https://pepy.tech/projects/snakefmt)
|
|
84
82
|
|
|
85
83
|
```sh
|
|
86
84
|
pip install snakefmt
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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\
|
|
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
|
|
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]\
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
1502
|
-
expected = f
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2493
|
-
expected = formatted1 + "\n\
|
|
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
|
+
)
|
|
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
|
|
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
|