sarj-python-lint 0.11.0__tar.gz → 0.11.1__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 (42) hide show
  1. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/_registry.py +0 -6
  4. sarj_python_lint-0.11.0/src/sarj_python_lint/rules/json_response_not_parsed.py +0 -110
  5. sarj_python_lint-0.11.0/src/sarj_python_lint/rules/len_as_truthiness.py +0 -126
  6. sarj_python_lint-0.11.0/src/sarj_python_lint/rules/no_manual_log_prefix.py +0 -146
  7. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/.gitignore +0 -0
  8. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/README.md +0 -0
  9. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/_secret_names.py +0 -0
  12. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/py.typed +0 -0
  13. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rule_base.py +0 -0
  14. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  15. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  16. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  17. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  18. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  19. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  20. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  21. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  22. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  23. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  24. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  25. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  26. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  27. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  28. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  29. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  30. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  31. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  32. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  33. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  34. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  35. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  36. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  37. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  38. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  39. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  40. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  41. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/stepdown.py +0 -0
  42. {sarj_python_lint-0.11.0 → sarj_python_lint-0.11.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.11.0
3
+ Version: 0.11.1
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
6
6
  Project-URL: Repository, https://github.com/sarj-ai/standards
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.11.0"
3
+ version = "0.11.1"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -5,8 +5,6 @@ from typing import TYPE_CHECKING
5
5
  from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
6
6
  InefficientStringConcatInLoop,
7
7
  )
8
- from sarj_python_lint.rules.json_response_not_parsed import JsonResponseNotParsed
9
- from sarj_python_lint.rules.len_as_truthiness import LenAsTruthiness
10
8
  from sarj_python_lint.rules.no_aggregation_in_store_query import (
11
9
  NoAggregationInStoreQuery,
12
10
  )
@@ -17,7 +15,6 @@ from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
17
15
  from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
18
16
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
19
17
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
20
- from sarj_python_lint.rules.no_manual_log_prefix import NoManualLogPrefix
21
18
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
22
19
  from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
23
20
  from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
@@ -64,10 +61,7 @@ REGISTRY: dict[str, type[Rule]] = {
64
61
  NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
65
62
  NoOffsetPagination.id: NoOffsetPagination,
66
63
  PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
67
- LenAsTruthiness.id: LenAsTruthiness,
68
64
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
69
- NoManualLogPrefix.id: NoManualLogPrefix,
70
- JsonResponseNotParsed.id: JsonResponseNotParsed,
71
65
  NoSleepInTestBody.id: NoSleepInTestBody,
72
66
  PydanticAtBoundaries.id: PydanticAtBoundaries,
73
67
  NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
@@ -1,110 +0,0 @@
1
- """SARJ030: flag an external JSON payload consumed untyped.
2
-
3
- A `.json()` call (no args) or a `json.loads(...)` / `json.load(...)` result used
4
- *directly* — subscripted (`["k"]`), `.get(...)`-ed, or attribute-chained — reaches
5
- into an external payload's shape without ever parsing it into a pydantic model.
6
- When the vendor changes that shape the access breaks silently at runtime instead
7
- of at a validated boundary.
8
-
9
- resp.json()["access_token"] # fires
10
- resp.json().get("data") # fires
11
- (await client.get(u)).json()["k"] # fires
12
- json.loads(body)["id"] # fires
13
-
14
- Parse the payload first: `Token.model_validate(resp.json())`, then read typed
15
- attributes off the model.
16
-
17
- This is an access-shape signal, distinct from the annotation-based SARJ008 /
18
- ANN401 (`dict[str, Any]` parameters): those catch an untyped *type*, this catches
19
- an untyped *use* of a live external response with no annotation at all.
20
-
21
- Deliberately NOT flagged (keeps false positives at zero):
22
- - assign-then-subscript (`d = resp.json(); d["k"]`) — only DIRECT chaining off the
23
- `.json()` / `json.loads()` call is tracked; a bound name could be anything,
24
- - a `.json(...)` call WITH arguments — that receiver is not the payload accessor,
25
- - the payload passed to a parser (`Model.model_validate(resp.json())`) rather than
26
- subscripted — that IS the parse we want,
27
- - a plain dict/list literal subscript (`{"a": 1}["a"]`),
28
- - a bare `.json` attribute that is never called, and `json.dumps(...)`.
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- import ast
34
- from typing import TYPE_CHECKING, override
35
-
36
- from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
37
-
38
-
39
- if TYPE_CHECKING:
40
- from pathlib import Path
41
-
42
-
43
- class JsonResponseNotParsed(Rule):
44
- """External JSON payload consumed untyped instead of parsed into a model."""
45
-
46
- id: str = "json-response-not-parsed"
47
- code: str = "SARJ030"
48
- description: str = (
49
- "A `.json()` / `json.loads()` result is subscripted, `.get()`-ed, or "
50
- "attribute-chained directly — parse it into a pydantic model first."
51
- )
52
-
53
- @override
54
- def check(self, path: Path, source: str) -> list[Diagnostic]:
55
- tree = parse_or_none(path, source)
56
- if tree is None:
57
- return []
58
- diags: list[Diagnostic] = []
59
- for node in ast.walk(tree):
60
- value = _direct_accessed_value(node)
61
- if value is None or not _is_json_payload_call(value):
62
- continue
63
- diags.append(
64
- Diagnostic(
65
- path=path,
66
- line=node.lineno,
67
- col=node.col_offset + 1,
68
- code=self.code,
69
- message=(
70
- "External JSON payload consumed untyped — parse the "
71
- "`.json()` / `json.loads()` result into a pydantic model "
72
- "before subscripting or attribute access."
73
- ),
74
- )
75
- )
76
- diags.sort(key=lambda d: (d.line, d.col))
77
- return diags
78
-
79
-
80
- def _direct_accessed_value(node: ast.AST) -> ast.expr | None:
81
- """The receiver being read off, if `node` reaches directly into a value.
82
-
83
- Covers the three access shapes — `<value>[...]` (Subscript), `<value>.attr`
84
- (Attribute, which also covers `<value>.get(...)` since `.get` is an
85
- attribute), and nothing else. Returns the `<value>` expression, or None.
86
- """
87
- if isinstance(node, ast.Subscript):
88
- return node.value
89
- if isinstance(node, ast.Attribute):
90
- return node.value
91
- return None
92
-
93
-
94
- def _is_json_payload_call(node: ast.expr) -> bool:
95
- """True for a JSON-payload-producing call: `<x>.json()` with NO args, or
96
- `json.loads(...)` / `json.load(...)`.
97
-
98
- A `.json(...)` call with any positional or keyword argument is not the payload
99
- accessor and is skipped.
100
- """
101
- if not isinstance(node, ast.Call):
102
- return False
103
- func = node.func
104
- if not isinstance(func, ast.Attribute):
105
- return False
106
- if func.attr == "json":
107
- return not node.args and not node.keywords
108
- if func.attr in {"loads", "load"}:
109
- return isinstance(func.value, ast.Name) and func.value.id == "json"
110
- return False
@@ -1,126 +0,0 @@
1
- """SARJ027: flag `len(x) <cmp> 0|1` comparisons that are just a truthiness test.
2
-
3
- Six zero-boundary forms of `len(x)` compared against `0` or `1` are exactly
4
- equivalent to testing the container's truthiness — an empty container is falsy,
5
- a non-empty one is truthy:
6
-
7
- len(x) == 0 -> not x
8
- len(x) < 1 -> not x
9
- len(x) <= 0 -> not x
10
- len(x) != 0 -> x
11
- len(x) > 0 -> x
12
- len(x) >= 1 -> x
13
-
14
- `len(x)` allocates nothing but reads as size arithmetic; the truthiness form is
15
- shorter and states the intent. pylint shipped this as C1802/C1803 but neither
16
- check was ever ported to ruff, so it is genuinely uncovered.
17
-
18
- Deliberately NOT flagged — these are real size checks, not truthiness:
19
- - exact-count / other-boundary comparisons (`len(x) == 1`, `>= 2`, `> 1`, `< 2`,
20
- `== 3`, ...); only the six zero-boundary forms above collapse to truthiness,
21
- - a right-hand constant other than `0`/`1` (or a bool literal `True`/`False`),
22
- - anything that is not literally `len(<one expr>)` on the left — a different
23
- builtin (`count(x) == 0`), `len` with 0/2+ args, or `len` called via attribute,
24
- - chained comparisons (`0 < len(x) < 5`) — more than one operator,
25
- - `x == []` / `x == {}` — a different check (it excludes `None`; see the
26
- separate empty-collection-literal rule).
27
-
28
- The yoda form `0 == len(x)` is intentionally not handled (rare; would widen the
29
- signal for little gain).
30
-
31
- References:
32
- - https://pylint.readthedocs.io/en/latest/user_guide/messages/refactor/use-implicit-booleaness-not-len.html
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import ast
38
- from typing import TYPE_CHECKING, override
39
-
40
- from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
41
-
42
-
43
- if TYPE_CHECKING:
44
- from pathlib import Path
45
-
46
-
47
- # (comparison-operator type, right-hand int constant) pairs that are equivalent to a
48
- # plain truthiness test, mapped to the truthy suggestion ("x") or falsy one ("not x").
49
- _TRUTHINESS_FORMS: dict[tuple[type[ast.cmpop], int], str] = {
50
- (ast.Eq, 0): "not x",
51
- (ast.Lt, 1): "not x",
52
- (ast.LtE, 0): "not x",
53
- (ast.NotEq, 0): "x",
54
- (ast.Gt, 0): "x",
55
- (ast.GtE, 1): "x",
56
- }
57
-
58
-
59
- class LenAsTruthiness(Rule):
60
- """`len(x) <cmp> 0|1` zero-boundary comparisons — prefer a plain truthiness test."""
61
-
62
- id: str = "len-as-truthiness"
63
- code: str = "SARJ027"
64
- description: str = (
65
- "len(x) compared against 0/1 at a zero boundary is a truthiness test — "
66
- "use `x` / `not x` instead of `len(x)`."
67
- )
68
-
69
- @override
70
- def check(self, path: Path, source: str) -> list[Diagnostic]:
71
- tree = parse_or_none(path, source)
72
- if tree is None:
73
- return []
74
- diags: list[Diagnostic] = []
75
- for node in ast.walk(tree):
76
- if not isinstance(node, ast.Compare):
77
- continue
78
- suggestion = _truthiness_suggestion(node)
79
- if suggestion is None:
80
- continue
81
- diags.append(
82
- Diagnostic(
83
- path=path,
84
- line=node.lineno,
85
- col=node.col_offset + 1,
86
- code=self.code,
87
- message=(
88
- f"len(...) compared against a zero boundary is a truthiness "
89
- f"test — use `{suggestion}` instead of `len(...)`."
90
- ),
91
- )
92
- )
93
- diags.sort(key=lambda d: (d.line, d.col))
94
- return diags
95
-
96
-
97
- def _truthiness_suggestion(node: ast.Compare) -> str | None:
98
- """The `x`/`not x` suggestion if `node` is a zero-boundary `len(...)` compare, else None."""
99
- if len(node.ops) != 1 or len(node.comparators) != 1:
100
- return None
101
- if not _is_len_call(node.left):
102
- return None
103
- const = _int_constant(node.comparators[0])
104
- if const is None:
105
- return None
106
- return _TRUTHINESS_FORMS.get((type(node.ops[0]), const))
107
-
108
-
109
- def _is_len_call(expr: ast.expr) -> bool:
110
- """True iff `expr` is literally `len(<single positional expr>)`."""
111
- if not isinstance(expr, ast.Call):
112
- return False
113
- if not (isinstance(expr.func, ast.Name) and expr.func.id == "len"):
114
- return False
115
- if len(expr.args) != 1 or expr.keywords:
116
- return False
117
- return not isinstance(expr.args[0], ast.Starred)
118
-
119
-
120
- def _int_constant(expr: ast.expr) -> int | None:
121
- """The int value of a plain integer literal, else None. Bool literals are excluded."""
122
- if not isinstance(expr, ast.Constant):
123
- return None
124
- if type(expr.value) is not int:
125
- return None
126
- return expr.value
@@ -1,146 +0,0 @@
1
- """SARJ029: flag a manual `"[Component] ..."` bracket tag in a logging message.
2
-
3
- The house logging style is structured: identify the emitting component with a
4
- bound field so aggregators can filter on it, rather than baking the label into
5
- the message text where it can only be grepped:
6
-
7
- # flagged
8
- logger.info("[STT] transcription finished")
9
-
10
- # preferred
11
- logger.bind(component="STT").info("transcription finished")
12
-
13
- A leading `[Tag]` in the literal message duplicates context that belongs in
14
- structured metadata and fragments template grouping. This is distinct from
15
- SARJ017 (no-fstring-in-log) / ruff G004, which fire only on *interpolating*
16
- f-strings; the messages caught here are plain string literals.
17
-
18
- To keep false positives near zero the rule requires BOTH a logger-like receiver
19
- (resolved through builder/factory chains via `is_logger_expr`) AND a logging
20
- method name, and only inspects the message argument (arg 1 for
21
- `logger.log(LEVEL, msg)`, arg 0 otherwise).
22
-
23
- The bracket tag must be a *component* label, not incidental data. The match
24
- pattern allows only letters, spaces, underscores and hyphens inside the
25
- brackets and requires at least one letter, so data-shaped brackets never match:
26
- `[Errno 2]` (digit), `[1, 2]` (digit/comma), `['a', 'b']` (quotes/comma),
27
- `[12:34]` (colon) are all excluded, while `[STT]`, `[TTS Pronunciation]` and
28
- `[AgentDispatch]` match. Empirically this matches 0 data brackets across the
29
- bulbul (273 hits) and noura (24 hits) corpora.
30
-
31
- Suppress an intentional case with `# sarj-noqa: SARJ029 — <reason>`.
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- import ast
37
- import re
38
- from typing import TYPE_CHECKING, override
39
-
40
- from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
41
- from sarj_python_lint.rules._logging import is_logger_expr
42
-
43
-
44
- if TYPE_CHECKING:
45
- from pathlib import Path
46
-
47
-
48
- _LOG_METHODS = frozenset(
49
- {
50
- "debug",
51
- "info",
52
- "warning",
53
- "warn",
54
- "error",
55
- "exception",
56
- "critical",
57
- "fatal",
58
- "trace",
59
- "success",
60
- "log",
61
- }
62
- )
63
-
64
- _PREFIX_RE = re.compile(r"^\[[A-Za-z_ -]*[A-Za-z][A-Za-z_ -]*\]")
65
-
66
- _LOG_LEVEL_AND_MESSAGE_ARGC = 2
67
-
68
-
69
- class NoManualLogPrefix(Rule):
70
- """Manual `[Component]` prefix in a logging message — bind it as structured context."""
71
-
72
- id: str = "no-manual-log-prefix"
73
- code: str = "SARJ029"
74
- description: str = (
75
- "manual '[Component]' prefix in a logging message — bind the component as "
76
- "structured context (logger.bind(component=...)) so aggregators filter on a "
77
- "field instead of grepping baked-in text."
78
- )
79
-
80
- @override
81
- def check(self, path: Path, source: str) -> list[Diagnostic]:
82
- tree = parse_or_none(path, source)
83
- if tree is None:
84
- return []
85
- diags: list[Diagnostic] = []
86
- for node in ast.walk(tree):
87
- if not isinstance(node, ast.Call) or not _is_logging_call(node):
88
- continue
89
- message = _message_arg(node)
90
- if message is None:
91
- continue
92
- leading = _leading_static_str(message)
93
- if leading is None or not _PREFIX_RE.match(leading):
94
- continue
95
- diags.append(
96
- Diagnostic(
97
- path=path,
98
- line=node.lineno,
99
- col=node.col_offset + 1,
100
- code=self.code,
101
- message=(
102
- "manual '[Component]' prefix in a logging message — bind it as "
103
- "structured context (logger.bind(component=...)) instead."
104
- ),
105
- )
106
- )
107
- diags.sort(key=lambda d: (d.line, d.col))
108
- return diags
109
-
110
-
111
- def _is_logging_call(node: ast.Call) -> bool:
112
- func = node.func
113
- if not isinstance(func, ast.Attribute) or func.attr not in _LOG_METHODS:
114
- return False
115
- return is_logger_expr(func.value)
116
-
117
-
118
- def _message_arg(node: ast.Call) -> ast.expr | None:
119
- """The message argument: index 1 for `logger.log(LEVEL, msg)`, else index 0.
120
-
121
- A `.log` call carrying fewer than two positionals has no message argument (the
122
- lone positional is the level), so there is nothing to inspect.
123
- """
124
- if isinstance(node.func, ast.Attribute) and node.func.attr == "log":
125
- return node.args[1] if len(node.args) >= _LOG_LEVEL_AND_MESSAGE_ARGC else None
126
- return node.args[0] if node.args else None
127
-
128
-
129
- def _leading_static_str(node: ast.expr) -> str | None:
130
- """The leading static string of a message expr, or None if there isn't one.
131
-
132
- Handles a bare string literal, the leading literal chunk of an f-string, and
133
- the left operand of a `+`-concatenation (recursively), so a baked-in prefix
134
- is found even when the value is later interpolated or concatenated.
135
- """
136
- match node:
137
- case ast.Constant(value=str() as value):
138
- return value
139
- case ast.JoinedStr(values=[ast.Constant(value=str() as value), *_]):
140
- return value
141
- case ast.JoinedStr():
142
- return None
143
- case ast.BinOp(op=ast.Add(), left=left):
144
- return _leading_static_str(left)
145
- case _:
146
- return None