repostyle 0.28.0__py3-none-any.whl

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.
repostyle/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Shared repo-style lint rules for gradienthealth Python repos."""
2
+
3
+ from repostyle.rules import ALL_RULE_IDS, RULES, Violation
4
+
5
+ __all__ = ["ALL_RULE_IDS", "RULES", "Violation"]
repostyle/_comments.py ADDED
@@ -0,0 +1,529 @@
1
+ """The cross-language `#`-comment extractor shared by the comment rules.
2
+
3
+ RS009 (paragraph wrapping), RS022 (tag format), RS030 (terminal punctuation),
4
+ and the suppression parser each read `#` comments from Python, TOML, YAML, and
5
+ shell. This module holds the one extractor they share: Python through the
6
+ tokenizer; TOML, YAML, and shell through a string- and block-aware line scan.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import re
13
+ import tokenize
14
+ from collections.abc import Iterator
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+ from typing import NamedTuple
18
+
19
+ # A YAML block scalar introducer: a value that, after a `:` or `-` lead or
20
+ # standing at the line start, is a `|` or `>` carrying only chomping and indent
21
+ # indicators, so the indented lines that follow are literal.
22
+ _BLOCK_SCALAR_PATTERN = re.compile(r"(?:[:-]\s+|^\s*)[|>][0-9+-]*\s*$")
23
+
24
+ # The file types whose `#` comments the comment rules read. Python is
25
+ # tokenized; TOML, YAML, and shell are scanned line by line. A type absent here
26
+ # is never comment-checked.
27
+ COMMENT_SUFFIXES = frozenset({".py", ".toml", ".yaml", ".yml", ".sh"})
28
+
29
+
30
+ class _CommentToken(NamedTuple):
31
+ lineno: int
32
+ """1-based line the comment starts on."""
33
+ column: int
34
+ """0-based column of the leading hash."""
35
+ string: str
36
+ """The comment from its leading hash to the end of the line."""
37
+ is_trailing: bool
38
+ """Whether code or data precedes the comment on its line."""
39
+
40
+
41
+ # Cache on (path, source) so a file is scanned once and the result shared
42
+ # across the rules that read it -- RS009, RS022, RS030, and the suppression
43
+ # parser -- the way `_parse_python` caches the AST. A tuple is returned so the
44
+ # cached value is safe to iterate repeatedly.
45
+ @lru_cache(maxsize=128)
46
+ def extract_comments(path: Path, source: str) -> tuple[_CommentToken, ...]:
47
+ """Returns each `#` comment in `source`, dispatched by file type.
48
+
49
+ A Python file is tokenized. A TOML, YAML, or shell file is scanned line by
50
+ line under that language's string and block rules, so a `#` inside a
51
+ string, a TOML multi-line string, a YAML block scalar, or a shell heredoc
52
+ is not mistaken for a comment. A file of any other type yields nothing. The
53
+ scan is conservative: an unrecognised construct keeps its `#` out of the
54
+ results rather than risk flagging string content.
55
+ """
56
+ suffix = path.suffix
57
+ if suffix == ".py":
58
+ return tuple(_python_comments(source))
59
+ if suffix == ".toml":
60
+ return tuple(_toml_comments(source))
61
+ if suffix in {".yaml", ".yml"}:
62
+ return tuple(_yaml_comments(source))
63
+ if suffix == ".sh":
64
+ return tuple(_shell_comments(source))
65
+ return ()
66
+
67
+
68
+ def _python_comments(source: str) -> Iterator[_CommentToken]:
69
+ """Yields each comment token in Python `source` via the tokenizer.
70
+
71
+ Tokens are yielded as the tokenizer produces them, so a fault in the tail
72
+ still surfaces the comments before it.
73
+ """
74
+ source_lines = source.splitlines()
75
+ try:
76
+ for token in tokenize.generate_tokens(io.StringIO(source).readline):
77
+ if token.type != tokenize.COMMENT:
78
+ continue
79
+ lineno, column = token.start
80
+ is_trailing = bool(source_lines[lineno - 1][:column].strip())
81
+ yield _CommentToken(lineno, column, token.string, is_trailing)
82
+ except (tokenize.TokenError, SyntaxError):
83
+ # An unterminated tail raises TokenError; inconsistent indentation
84
+ # raises IndentationError/TabError (SyntaxError subclasses). Stop at
85
+ # the fault and keep the comments already surfaced.
86
+ return
87
+
88
+
89
+ def _toml_comments(source: str) -> Iterator[_CommentToken]:
90
+ """Yields each `#` comment in TOML `source`, line by line.
91
+
92
+ A multi-line string spanning lines carries its closing delimiter forward in
93
+ `open_delimiter`, so a `#` inside it is never a comment.
94
+ """
95
+ open_delimiter: str | None = None
96
+ for lineno, line in enumerate(source.splitlines(), start=1):
97
+ column, open_delimiter = _toml_scan_line(line, open_delimiter)
98
+ if column is not None:
99
+ yield _token(lineno, line, column)
100
+
101
+
102
+ def _toml_scan_line(
103
+ line: str, open_delimiter: str | None
104
+ ) -> tuple[int | None, str | None]:
105
+ """Finds a `#` comment in one TOML line, tracking multi-line strings.
106
+
107
+ `open_delimiter`, when set, is the triple-quote delimiter closing an open
108
+ multi-line string; the scan resumes after it closes on this line. Returns
109
+ the comment column (or `None`) and the delimiter still open at the line's
110
+ end (or `None`).
111
+ """
112
+ index = 0
113
+ if open_delimiter is not None:
114
+ close = line.find(open_delimiter)
115
+ if close == -1:
116
+ return None, open_delimiter
117
+ index = close + len(open_delimiter)
118
+ while index < len(line):
119
+ char = line[index]
120
+ if char == "#":
121
+ return index, None
122
+ if char in "\"'":
123
+ triple = line[index : index + 3]
124
+ if triple in ('"""', "'''"):
125
+ close = line.find(triple, index + 3)
126
+ if close == -1:
127
+ return None, triple
128
+ index = close + 3
129
+ continue
130
+ index = _skip_toml_string(line, index)
131
+ continue
132
+ index += 1
133
+ return None, None
134
+
135
+
136
+ def _skip_toml_string(line: str, index: int) -> int:
137
+ """Returns the index past the single-line string opening at `index`.
138
+
139
+ A basic (`"`) string honours backslash escapes; a literal (`'`) string does
140
+ not. An unterminated string consumes the rest of the line, so its content
141
+ is never read as a comment.
142
+ """
143
+ quote = line[index]
144
+ index += 1
145
+ while index < len(line):
146
+ if quote == '"' and line[index] == "\\":
147
+ index += 2
148
+ continue
149
+ if line[index] == quote:
150
+ return index + 1
151
+ index += 1
152
+ return len(line)
153
+
154
+
155
+ def _yaml_comments(source: str) -> Iterator[_CommentToken]:
156
+ """Yields each `#` comment in YAML `source`, line by line.
157
+
158
+ A `|` or `>` block scalar records its introducer indent in `block_indent`;
159
+ the deeper-indented lines that follow are literal, so a `#` among them is
160
+ never read as a comment.
161
+ """
162
+ block_indent: int | None = None
163
+ for lineno, line in enumerate(source.splitlines(), start=1):
164
+ if block_indent is not None and _inside_block(line, block_indent):
165
+ continue
166
+ block_indent = None
167
+ column = _yaml_comment_column(line)
168
+ content = line if column is None else line[:column]
169
+ if _opens_block_scalar(content):
170
+ block_indent = _indent_of(line)
171
+ if column is not None:
172
+ yield _token(lineno, line, column)
173
+
174
+
175
+ def _inside_block(line: str, block_indent: int) -> bool:
176
+ """Reports whether `line` sits in a block scalar at `block_indent`."""
177
+ return not line.strip() or _indent_of(line) > block_indent
178
+
179
+
180
+ def _indent_of(line: str) -> int:
181
+ """Returns the count of leading spaces on `line`."""
182
+ return len(line) - len(line.lstrip(" "))
183
+
184
+
185
+ def _opens_block_scalar(content: str) -> bool:
186
+ """Reports whether `content` is a YAML line opening a block scalar."""
187
+ return _BLOCK_SCALAR_PATTERN.search(content) is not None
188
+
189
+
190
+ def _yaml_comment_column(line: str) -> int | None:
191
+ """Returns the column of a `#` comment in one YAML line, or `None`.
192
+
193
+ A `#` opens a comment only at the line start or after whitespace, and never
194
+ inside a quoted scalar. A double-quoted scalar honours backslash escapes; a
195
+ single-quoted one escapes a quote by doubling it.
196
+ """
197
+ index = 0
198
+ while index < len(line):
199
+ char = line[index]
200
+ if char == "#" and (index == 0 or line[index - 1] in " \t"):
201
+ return index
202
+ if char == '"':
203
+ index = _skip_yaml_double(line, index)
204
+ continue
205
+ if char == "'":
206
+ index = _skip_yaml_single(line, index)
207
+ continue
208
+ index += 1
209
+ return None
210
+
211
+
212
+ def _skip_yaml_double(line: str, index: int) -> int:
213
+ """Returns the index past the double-quoted scalar opening at `index`."""
214
+ index += 1
215
+ while index < len(line):
216
+ if line[index] == "\\":
217
+ index += 2
218
+ continue
219
+ if line[index] == '"':
220
+ return index + 1
221
+ index += 1
222
+ return len(line)
223
+
224
+
225
+ def _skip_yaml_single(line: str, index: int) -> int:
226
+ """Returns the index past the single-quoted scalar opening at `index`."""
227
+ index += 1
228
+ while index < len(line):
229
+ if line[index] == "'":
230
+ if line[index + 1 : index + 2] == "'":
231
+ index += 2
232
+ continue
233
+ return index + 1
234
+ index += 1
235
+ return len(line)
236
+
237
+
238
+ # A bare heredoc delimiter word, optionally backslash-quoted (`<<\EOF`). A
239
+ # leading letter or underscore keeps a `<< 2` arithmetic shift from reading as
240
+ # a redirection, since a delimiter starting with a digit is not used in
241
+ # practice.
242
+ _HEREDOC_WORD_PATTERN = re.compile(r"\\?[A-Za-z_][A-Za-z0-9_]*")
243
+
244
+ # The shell contexts the scan nests, innermost last in `_ShellState.contexts`.
245
+ # A substitution marker also stands for a `(` group opened inside one, so the
246
+ # group's `)` does not close the substitution early.
247
+ _DOUBLE_QUOTED = '"'
248
+ _SINGLE_QUOTED = "'"
249
+ _SUBSTITUTION = "("
250
+ _BACKTICK = "`"
251
+
252
+
253
+ def _shell_comments(source: str) -> Iterator[_CommentToken]:
254
+ """Yields each `#` comment in shell `source`, line by line.
255
+
256
+ A `#` opens a comment only at the line start or after whitespace, so a
257
+ `${var#pat}` expansion, a `$#`, and a `#` glued inside a word stay code. A
258
+ quoted string, an ANSI-C `$'...'` string, and an arithmetic `$(( ))`
259
+ expansion are skipped, so a `#` inside them is never a comment; the open
260
+ contexts and any heredoc body carry forward in `_ShellState`. A `#!`
261
+ shebang is yielded like any comment.
262
+ """
263
+ state = _ShellState((), None)
264
+ for lineno, line in enumerate(source.splitlines(), start=1):
265
+ column, state = _shell_scan_line(line, state)
266
+ if column is not None:
267
+ yield _token(lineno, line, column)
268
+
269
+
270
+ def _shell_scan_line(line: str, state: _ShellState) -> tuple[int | None, _ShellState]:
271
+ """Finds a `#` comment in one shell line, resuming any cross-line state.
272
+
273
+ Contexts open from a prior line resume where they left off; a line inside a
274
+ heredoc body yields no comment until the terminator line closes it. Returns
275
+ the comment column (or `None`) and the state still open at the line's end.
276
+ """
277
+ if state.contexts:
278
+ return _shell_scan(line, state.contexts)
279
+ if state.heredoc is not None:
280
+ if _heredoc_terminated(line, state.heredoc):
281
+ return None, _ShellState((), None)
282
+ return None, state
283
+ return _shell_scan(line, ())
284
+
285
+
286
+ def _shell_scan(line: str, contexts: tuple[str, ...]) -> tuple[int | None, _ShellState]:
287
+ """Scans `line` for a `#` comment under the contexts open around it.
288
+
289
+ Quoting nests, so which characters matter depends on the innermost open
290
+ context rather than on the line alone: `$(...)` and a backtick substitution
291
+ start a fresh code context inside a double-quoted string, and a quote of
292
+ the other style inside any string is literal. Tracking that is what keeps a
293
+ `sed 's/"/X/'` inside `"$( )"` from closing the outer string and stranding
294
+ every later `#` inside a phantom one.
295
+
296
+ Outside a string, a backslash-escaped character, an ANSI-C `$'...'` string,
297
+ an arithmetic `$(( ))` expansion, and a `<<<` here-string are skipped
298
+ whole, so neither a `#` inside one nor a here-string's own `<<` is misread.
299
+ A heredoc redirection is recorded but the scan continues, so a trailing
300
+ comment on the redirection line is still found.
301
+
302
+ Returns:
303
+ The `#` column (or `None`) and the state open at the line's end.
304
+ """
305
+ stack = list(contexts)
306
+ pending: _Heredoc | None = None
307
+ index = 0
308
+ while index < len(line):
309
+ innermost = stack[-1] if stack else None
310
+ if innermost == _SINGLE_QUOTED:
311
+ index = _scan_single_quoted(line, index, stack)
312
+ continue
313
+ if innermost == _DOUBLE_QUOTED:
314
+ index = _scan_double_quoted(line, index, stack)
315
+ continue
316
+ if line[index] == "#" and _opens_comment(line, index):
317
+ return index, _ShellState(tuple(stack), pending)
318
+ step = _shell_skip(line, index)
319
+ if step is not None:
320
+ index = step
321
+ continue
322
+ opener = _heredoc_opener(line, index)
323
+ if opener is not None:
324
+ heredoc, index = opener
325
+ pending = pending or heredoc
326
+ continue
327
+ index = _scan_code(line, index, stack)
328
+ return None, _ShellState(tuple(stack), pending)
329
+
330
+
331
+ def _heredoc_opener(line: str, index: int) -> tuple[_Heredoc, int] | None:
332
+ """Parses a `<<WORD` heredoc redirection at `index`, or returns `None`.
333
+
334
+ Handles `<<`, the tab-stripping `<<-`, and a quoted (`<<'EOF'`) or bare
335
+ delimiter. Returns the heredoc and the index past the delimiter, so the
336
+ rest of the line still scans for a trailing comment.
337
+ """
338
+ if not line.startswith("<<", index):
339
+ return None
340
+ cursor = index + 2
341
+ has_tab_stripping = line[cursor : cursor + 1] == "-"
342
+ if has_tab_stripping:
343
+ cursor += 1
344
+ while cursor < len(line) and line[cursor] in " \t":
345
+ cursor += 1
346
+ terminator, cursor = _heredoc_delimiter(line, cursor)
347
+ if terminator is None:
348
+ return None
349
+ return _Heredoc(terminator, has_tab_stripping), cursor
350
+
351
+
352
+ def _heredoc_delimiter(line: str, index: int) -> tuple[str | None, int]:
353
+ """Reads a heredoc delimiter word at `index`, quoted or bare.
354
+
355
+ Returns the unquoted terminator and the index past it, or `(None, index)`
356
+ when no delimiter word follows, so the `<<` reads as a shift operator
357
+ rather than a redirection.
358
+ """
359
+ if index < len(line) and line[index] in "\"'":
360
+ quote = line[index]
361
+ close = line.find(quote, index + 1)
362
+ if close == -1:
363
+ return None, index
364
+ return line[index + 1 : close], close + 1
365
+ match = _HEREDOC_WORD_PATTERN.match(line, index)
366
+ if match is None:
367
+ return None, index
368
+ return match.group().lstrip("\\"), match.end()
369
+
370
+
371
+ def _heredoc_terminated(line: str, heredoc: _Heredoc) -> bool:
372
+ """Reports whether `line` is the delimiter ending a heredoc body.
373
+
374
+ A `<<-` heredoc lets the delimiter line carry leading tabs, so those are
375
+ stripped before the comparison; otherwise the line must equal the delimiter
376
+ exactly.
377
+ """
378
+ candidate = line.lstrip("\t") if heredoc.has_tab_stripping else line
379
+ return candidate == heredoc.terminator
380
+
381
+
382
+ def _opens_comment(line: str, index: int) -> bool:
383
+ """Reports whether the `#` at `index` begins a shell comment.
384
+
385
+ A `#` begins a comment only at the line start or after whitespace, so a `#`
386
+ glued to a word (`$#`, `${v#p}`, `a#b`) stays code.
387
+ """
388
+ return index == 0 or line[index - 1] in " \t"
389
+
390
+
391
+ def _scan_code(line: str, index: int, stack: list[str]) -> int:
392
+ """Advances one character through a code context, updating `stack`.
393
+
394
+ A quote opens the string it delimits. A backtick opens a substitution, or
395
+ closes the one it already opened. A `$(` opens a command substitution, and
396
+ a `(` nested inside one is pushed too so its `)` does not close the
397
+ substitution early. A `(` at the top level is ignored, since a `case`
398
+ pattern and a function header carry unpaired parentheses.
399
+ """
400
+ char = line[index]
401
+ innermost = stack[-1] if stack else None
402
+ if char == _BACKTICK:
403
+ if innermost == _BACKTICK:
404
+ stack.pop()
405
+ else:
406
+ stack.append(_BACKTICK)
407
+ return index + 1
408
+ if line.startswith("$(", index):
409
+ stack.append(_SUBSTITUTION)
410
+ return index + 2
411
+ if char in (_DOUBLE_QUOTED, _SINGLE_QUOTED):
412
+ stack.append(char)
413
+ return index + 1
414
+ if char == "(" and _SUBSTITUTION in stack:
415
+ stack.append(_SUBSTITUTION)
416
+ elif char == ")" and innermost == _SUBSTITUTION:
417
+ stack.pop()
418
+ return index + 1
419
+
420
+
421
+ def _scan_double_quoted(line: str, index: int, stack: list[str]) -> int:
422
+ r"""Advances one character through a double-quoted string.
423
+
424
+ A backslash escapes the next character, so `\"` does not end the string,
425
+ and a `'` inside is literal. A `$(` or a backtick opens a nested code
426
+ context where quoting starts afresh, so a quote inside it belongs to that
427
+ context rather than closing this string. `stack` is pushed or popped
428
+ accordingly.
429
+ """
430
+ char = line[index]
431
+ if char == "\\":
432
+ return index + 2
433
+ if char == _DOUBLE_QUOTED:
434
+ stack.pop()
435
+ return index + 1
436
+ if line.startswith("$(", index):
437
+ stack.append(_SUBSTITUTION)
438
+ return index + 2
439
+ if char == _BACKTICK:
440
+ stack.append(_BACKTICK)
441
+ return index + 1
442
+ return index + 1
443
+
444
+
445
+ def _scan_single_quoted(line: str, index: int, stack: list[str]) -> int:
446
+ r"""Advances one character through a single-quoted string.
447
+
448
+ A single-quoted string has no escapes at all, so a backslash is literal
449
+ there and only a `'` ends it, popping `stack`. That is why `'it'\''s'` is
450
+ three adjacent strings rather than one holding an escaped quote, and why a
451
+ `"` inside is just a character.
452
+ """
453
+ if line[index] == _SINGLE_QUOTED:
454
+ stack.pop()
455
+ return index + 1
456
+
457
+
458
+ def _shell_skip(line: str, index: int) -> int | None:
459
+ r"""Returns the index past a construct skipped whole, or `None`.
460
+
461
+ A backslash escapes the next character, including a line-continuation `\`
462
+ at the line's end; `$'...'` is an ANSI-C string honouring backslash
463
+ escapes; `$(( ))` is an arithmetic expansion whose `#` base marker and `<<`
464
+ shift must not read as a comment or a heredoc; `<<<` is a here-string
465
+ operator, whose three characters skip together so the trailing `<<` cannot
466
+ open a spurious heredoc.
467
+ """
468
+ if line[index] == "\\":
469
+ return index + 2
470
+ if line.startswith("$'", index):
471
+ return _shell_ansi_c_end(line, index + 2)
472
+ if line.startswith("$((", index):
473
+ return _shell_arithmetic_end(line, index + 3)
474
+ if line.startswith("<<<", index):
475
+ return index + 3
476
+ return None
477
+
478
+
479
+ def _shell_ansi_c_end(line: str, index: int) -> int:
480
+ r"""Returns the index past an ANSI-C `$'...'` string opened before `index`.
481
+
482
+ A backslash escapes the next character, so `\'` does not close the string.
483
+ An unterminated string consumes the rest of the line.
484
+ """
485
+ while index < len(line):
486
+ if line[index] == "\\":
487
+ index += 2
488
+ continue
489
+ if line[index] == "'":
490
+ return index + 1
491
+ index += 1
492
+ return len(line)
493
+
494
+
495
+ def _shell_arithmetic_end(line: str, index: int) -> int:
496
+ """Returns the index past a `$(( ))` expansion opened before `index`.
497
+
498
+ Tracks parenthesis depth so a nested `(` pairs before the closing `))`. An
499
+ unterminated expansion consumes the rest of the line.
500
+ """
501
+ depth = 2
502
+ while index < len(line):
503
+ if line[index] == "(":
504
+ depth += 1
505
+ elif line[index] == ")":
506
+ depth -= 1
507
+ if depth == 0:
508
+ return index + 1
509
+ index += 1
510
+ return len(line)
511
+
512
+
513
+ def _token(lineno: int, line: str, column: int) -> _CommentToken:
514
+ """Builds a comment token for the `#` at `column` on `line`."""
515
+ return _CommentToken(lineno, column, line[column:], bool(line[:column].strip()))
516
+
517
+
518
+ class _Heredoc(NamedTuple):
519
+ terminator: str
520
+ """The word whose own line ends the heredoc body."""
521
+ has_tab_stripping: bool
522
+ """Whether a `<<-` heredoc lets the terminator line carry leading tabs."""
523
+
524
+
525
+ class _ShellState(NamedTuple):
526
+ contexts: tuple[str, ...]
527
+ """Strings and substitutions open at a line's end, innermost last."""
528
+ heredoc: _Heredoc | None
529
+ """An open heredoc whose body suppresses comments, else `None`."""