sarj-python-lint 0.4.0__tar.gz → 0.5.0__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 (21) hide show
  1. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/PKG-INFO +2 -1
  2. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/README.md +1 -0
  3. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/__init__.py +4 -0
  5. sarj_python_lint-0.5.0/src/sarj_python_lint/rules/no_isinstance_union_chain.py +183 -0
  6. sarj_python_lint-0.5.0/src/sarj_python_lint/rules/prefer_class_row.py +85 -0
  7. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/prefer_discriminated_union.py +37 -0
  8. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/.gitignore +0 -0
  9. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/py.typed +0 -0
  12. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rule_base.py +0 -0
  13. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  14. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  15. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  16. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  17. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  18. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  19. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  20. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  21. {sarj_python_lint-0.4.0 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -36,6 +36,7 @@ uv tool install sarj-python-lint
36
36
  - id: sarj-prefer-str-enum
37
37
  - id: sarj-no-fat-try-blocks
38
38
  - id: sarj-pydantic-at-boundaries
39
+ - id: sarj-prefer-class-row
39
40
  ```
40
41
 
41
42
  ## CLI
@@ -18,6 +18,7 @@ uv tool install sarj-python-lint
18
18
  - id: sarj-prefer-str-enum
19
19
  - id: sarj-no-fat-try-blocks
20
20
  - id: sarj-pydantic-at-boundaries
21
+ - id: sarj-prefer-class-row
21
22
  ```
22
23
 
23
24
  ## CLI
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.4.0"
3
+ version = "0.5.0"
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,12 +5,14 @@ from sarj_python_lint.rules.inefficient_string_concat_in_loop import (
5
5
  InefficientStringConcatInLoop,
6
6
  )
7
7
  from sarj_python_lint.rules.no_fat_try_blocks import NoFatTryBlocks
8
+ from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
8
9
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
9
10
  from sarj_python_lint.rules.no_sentinel_return_on_except import NoSentinelReturnOnExcept
10
11
  from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
11
12
  from sarj_python_lint.rules.no_unreachable_after_terminal import (
12
13
  NoUnreachableAfterTerminal,
13
14
  )
15
+ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
14
16
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
15
17
  PreferConstantTimeSecretCompare,
16
18
  )
@@ -23,8 +25,10 @@ REGISTRY: dict[str, type[Rule]] = {
23
25
  NoSequentialAwait.id: NoSequentialAwait,
24
26
  InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
25
27
  PreferDiscriminatedUnion.id: PreferDiscriminatedUnion,
28
+ PreferClassRow.id: PreferClassRow,
26
29
  PreferStrEnum.id: PreferStrEnum,
27
30
  NoFatTryBlocks.id: NoFatTryBlocks,
31
+ NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
28
32
  PydanticAtBoundaries.id: PydanticAtBoundaries,
29
33
  NoSentinelReturnOnExcept.id: NoSentinelReturnOnExcept,
30
34
  NoUnreachableAfterTerminal.id: NoUnreachableAfterTerminal,
@@ -0,0 +1,183 @@
1
+ """SARJ003: flag `if/elif isinstance(...)` chains that dispatch over a closed union.
2
+
3
+ A chain of `if isinstance(x, A): ... elif isinstance(x, B): ...` (2+ branches, same
4
+ target, each branch testing one locally-defined class) is almost always dispatch over a
5
+ closed discriminated union. `match`/`case` with `assert_never` in the fallthrough is
6
+ strictly better: pyright reports an error the moment a new variant is added and a branch
7
+ is missed — a plain `isinstance` chain silently falls through.
8
+
9
+ # flagged
10
+ if isinstance(subject, ApiKeySubject):
11
+ ...
12
+ elif isinstance(subject, JwtSubject):
13
+ ...
14
+
15
+ # preferred
16
+ match subject:
17
+ case ApiKeySubject():
18
+ ...
19
+ case JwtSubject():
20
+ ...
21
+ case _:
22
+ assert_never(subject)
23
+
24
+ This is a heuristic, not a proof the union is closed — so it accepts some false positives.
25
+ Suppress a deliberate boundary chain with `# sarj-noqa: SARJ003 — <reason>`.
26
+
27
+ Deliberately NOT flagged (boundary/runtime checks, not closed-union dispatch):
28
+ - a single `isinstance` guard (no chain),
29
+ - `isinstance(x, (A, B))` tuple-membership (one check, not a dispatch chain),
30
+ - any chain whose branches test builtins/stdlib types (`dict`, `str`, `list`, `Exception`,
31
+ `datetime`, ...), the generated-SDK `Unset` sentinel, or `collections.abc`/`typing` ABCs,
32
+ - any chain mixing `isinstance` with a non-`isinstance` condition (e.g. `hasattr`, a
33
+ comparison, a boolean combination) — a defensive guard, not a clean dispatch.
34
+
35
+ References:
36
+ - https://docs.python.org/3/library/typing.html#typing.assert_never
37
+ - https://typing.python.org/en/latest/spec/narrowing.html#assert-never-and-exhaustiveness-checking
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import ast
43
+ from pathlib import Path
44
+
45
+ from sarj_python_lint.rule_base import Diagnostic, Rule
46
+
47
+ _EXCLUDED_TYPE_NAMES = frozenset(
48
+ {
49
+ "dict",
50
+ "str",
51
+ "list",
52
+ "tuple",
53
+ "set",
54
+ "frozenset",
55
+ "int",
56
+ "float",
57
+ "bool",
58
+ "complex",
59
+ "bytes",
60
+ "bytearray",
61
+ "type",
62
+ "object",
63
+ "Exception",
64
+ "BaseException",
65
+ "NoneType",
66
+ "Unset",
67
+ "datetime",
68
+ "date",
69
+ "time",
70
+ "timedelta",
71
+ "Mapping",
72
+ "MutableMapping",
73
+ "Sequence",
74
+ "MutableSequence",
75
+ "Iterable",
76
+ "Iterator",
77
+ "Collection",
78
+ "Container",
79
+ "Set",
80
+ "Hashable",
81
+ "Callable",
82
+ }
83
+ )
84
+
85
+
86
+ class NoIsinstanceUnionChain(Rule):
87
+ """`if/elif isinstance` chains over local classes — prefer match/case + assert_never."""
88
+
89
+ id = "no-isinstance-union-chain"
90
+ code = "SARJ003"
91
+ description = (
92
+ "if/elif isinstance chain over local classes — prefer match/case with "
93
+ "assert_never for compile-time exhaustiveness."
94
+ )
95
+
96
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
97
+ try:
98
+ tree = ast.parse(source, filename=str(path))
99
+ except SyntaxError:
100
+ return []
101
+ elif_nodes = _collect_elif_nodes(tree)
102
+ diags: list[Diagnostic] = []
103
+ for node in ast.walk(tree):
104
+ if not isinstance(node, ast.If) or id(node) in elif_nodes:
105
+ continue
106
+ count = _qualifying_chain_length(node)
107
+ if count >= 2:
108
+ diags.append(
109
+ Diagnostic(
110
+ path=path,
111
+ line=node.lineno,
112
+ col=node.col_offset + 1,
113
+ code=self.code,
114
+ message=(
115
+ f"if/elif isinstance chain over {count} types — prefer "
116
+ "match/case with assert_never for exhaustiveness."
117
+ ),
118
+ )
119
+ )
120
+ return diags
121
+
122
+
123
+ def _collect_elif_nodes(tree: ast.AST) -> set[int]:
124
+ """ids of `If` nodes that are the sole `orelse` of another `If` (i.e. `elif` arms)."""
125
+ elifs: set[int] = set()
126
+ for node in ast.walk(tree):
127
+ if isinstance(node, ast.If) and len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
128
+ elifs.add(id(node.orelse[0]))
129
+ return elifs
130
+
131
+
132
+ def _qualifying_chain_length(head: ast.If) -> int:
133
+ """Number of branches if `head` is an all-`isinstance`-on-one-target chain, else 0.
134
+
135
+ Returns 0 if any branch is not `isinstance(<same target>, <single local class>)`.
136
+ """
137
+ target_dump: str | None = None
138
+ count = 0
139
+ current: ast.If | None = head
140
+ while current is not None:
141
+ type_arg = _isinstance_single_type(current.test)
142
+ if type_arg is None:
143
+ return 0
144
+ target, type_name = type_arg
145
+ if type_name in _EXCLUDED_TYPE_NAMES:
146
+ return 0
147
+ dumped = ast.dump(target)
148
+ if target_dump is None:
149
+ target_dump = dumped
150
+ elif dumped != target_dump:
151
+ return 0
152
+ count += 1
153
+ if len(current.orelse) == 1 and isinstance(current.orelse[0], ast.If):
154
+ current = current.orelse[0]
155
+ else:
156
+ current = None
157
+ return count
158
+
159
+
160
+ def _isinstance_single_type(test: ast.expr) -> tuple[ast.expr, str] | None:
161
+ """If `test` is `isinstance(x, SomeClass)` with a single Name/Attribute class, return
162
+ (target, class_name); else None. Tuple-form `isinstance(x, (A, B))` returns None."""
163
+ if not isinstance(test, ast.Call):
164
+ return None
165
+ if not (isinstance(test.func, ast.Name) and test.func.id == "isinstance"):
166
+ return None
167
+ if len(test.args) != 2 or test.keywords:
168
+ return None
169
+ target, type_node = test.args
170
+ name = _class_name(type_node)
171
+ if name is None:
172
+ return None
173
+ return target, name
174
+
175
+
176
+ def _class_name(node: ast.expr) -> str | None:
177
+ """The trailing name of a class reference: `Foo` / `mod.Foo` -> 'Foo'. None for tuples
178
+ or anything that isn't a plain Name/Attribute (e.g. a subscript or tuple-membership)."""
179
+ if isinstance(node, ast.Name):
180
+ return node.id
181
+ if isinstance(node, ast.Attribute):
182
+ return node.attr
183
+ return None
@@ -0,0 +1,85 @@
1
+ """SARJ013: psycopg `row_factory=dict_row` where a validated model row is intended.
2
+
3
+ The repo standard is to map each DB row straight into a pydantic model with
4
+ `class_row(Model)`, so every row is validated at the database boundary and the
5
+ cursor is typed `Cursor[Model]`. `dict_row` instead hands back an unvalidated
6
+ `dict[str, Any]` that callers then feed to `Model.model_validate(...)` by hand —
7
+ an extra step that is easy to forget and leaves the value untyped in between.
8
+
9
+ Flags any `row_factory=dict_row` keyword argument (typically on
10
+ `conn.cursor(...)`). If you genuinely need a plain mapping — an ad-hoc
11
+ aggregate, a `COUNT(*)`, or a dynamic projection with no model — suppress with
12
+ `# sarj-noqa: prefer-class-row — <reason>`.
13
+
14
+ Replace:
15
+ async with conn.cursor(row_factory=dict_row) as cur:
16
+ await cur.execute(..., RETURNING id, status)
17
+ row = await cur.fetchone()
18
+ return Task.model_validate(row)
19
+
20
+ with:
21
+ async with conn.cursor(row_factory=class_row(Task)) as cur:
22
+ await cur.execute(..., RETURNING id, status)
23
+ return one(await cur.fetchone())
24
+
25
+ References:
26
+ - https://www.psycopg.org/psycopg3/docs/api/rows.html#psycopg.rows.class_row
27
+ - https://docs.pydantic.dev/latest/concepts/models/#validating-data
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import ast
33
+ from pathlib import Path
34
+
35
+ from sarj_python_lint.rule_base import Diagnostic, Rule
36
+
37
+
38
+ _ROW_FACTORY_KW = "row_factory"
39
+ _BANNED_FACTORY = "dict_row"
40
+
41
+
42
+ class PreferClassRow(Rule):
43
+ """`row_factory=dict_row` returns unvalidated dicts — prefer `class_row(Model)`."""
44
+
45
+ id = "prefer-class-row"
46
+ code = "SARJ013"
47
+ description = "psycopg row_factory=dict_row returns unvalidated dicts — prefer class_row(Model)."
48
+
49
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
50
+ try:
51
+ tree = ast.parse(source, filename=str(path))
52
+ except SyntaxError:
53
+ return []
54
+ diags: list[Diagnostic] = []
55
+ for node in ast.walk(tree):
56
+ if not isinstance(node, ast.keyword):
57
+ continue
58
+ if node.arg != _ROW_FACTORY_KW:
59
+ continue
60
+ if _factory_name(node.value) != _BANNED_FACTORY:
61
+ continue
62
+ diags.append(
63
+ Diagnostic(
64
+ path=path,
65
+ line=node.value.lineno,
66
+ col=node.value.col_offset + 1,
67
+ code=self.code,
68
+ message=(
69
+ "`row_factory=dict_row` yields unvalidated dict rows — "
70
+ "prefer `class_row(YourModel)` to validate at the DB boundary "
71
+ "(suppress with `# sarj-noqa: prefer-class-row` for genuine ad-hoc shapes)"
72
+ ),
73
+ )
74
+ )
75
+ diags.sort(key=lambda d: (d.line, d.col))
76
+ return diags
77
+
78
+
79
+ def _factory_name(node: ast.expr) -> str | None:
80
+ """Resolve a `row_factory=` value to its callable name (`dict_row`, …)."""
81
+ if isinstance(node, ast.Name):
82
+ return node.id
83
+ if isinstance(node, ast.Attribute):
84
+ return node.attr
85
+ return None
@@ -43,6 +43,10 @@ Three triggers:
43
43
  (`*Input` / `*Params` / `*Filter` / `*Query` / `Update*` / `Patch*` /
44
44
  `Upsert*`) are excluded from this trigger.
45
45
 
46
+ A single-value `Literal` tag (e.g. `type: Literal["complete"]`) marks a model
47
+ that is already an arm of a discriminated union, so it is excluded too — a
48
+ multi-value `Literal[...]` is still treated as a poor-man's discriminator.
49
+
46
50
  References:
47
51
  - https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions
48
52
  - https://en.wikipedia.org/wiki/Tagged_union
@@ -131,6 +135,7 @@ class PreferDiscriminatedUnion(Rule):
131
135
  if not (is_model or is_dc):
132
136
  return None
133
137
  has_status_bool = False
138
+ has_literal_tag = False
134
139
  optional_fields: list[str] = []
135
140
  discriminator_fields: list[str] = []
136
141
  for stmt in node.body:
@@ -146,6 +151,8 @@ class PreferDiscriminatedUnion(Rule):
146
151
  stmt.annotation, str_enum_names
147
152
  ):
148
153
  discriminator_fields.append(name)
154
+ if _is_single_value_literal(stmt.annotation):
155
+ has_literal_tag = True
149
156
  if _is_optional(stmt.annotation):
150
157
  if name not in IGNORED_OPTIONAL_FIELDS:
151
158
  optional_fields.append(name)
@@ -164,10 +171,13 @@ class PreferDiscriminatedUnion(Rule):
164
171
  ),
165
172
  )
166
173
  # Nullable-cluster trigger: discriminator-ish field + 3 or more nullables.
174
+ # A single-value `Literal` tag (e.g. `type: Literal["complete"]`) marks a
175
+ # model that is already a discriminated-union arm, not a poor-man's result.
167
176
  if (
168
177
  discriminator_fields
169
178
  and len(optional_fields) >= NULLABLE_CLUSTER_THRESHOLD
170
179
  and not _is_dto_class_name(node.name)
180
+ and not has_literal_tag
171
181
  ):
172
182
  return Diagnostic(
173
183
  path=path,
@@ -190,6 +200,33 @@ def _is_dto_class_name(name: str) -> bool:
190
200
  return name.endswith(DTO_CLASS_NAME_SUFFIXES) or name.startswith(DTO_CLASS_NAME_PREFIXES)
191
201
 
192
202
 
203
+ def _is_single_value_literal(node: ast.AST | None) -> bool:
204
+ """Detect a single-constant `Literal[X]` annotation.
205
+
206
+ A one-element `Literal` (e.g. `type: Literal["complete"]`) is the canonical
207
+ tag of a discriminated-union arm, so a model carrying one is already modelled
208
+ correctly. A multi-value `Literal[...]` is still a poor-man's discriminator.
209
+ """
210
+ if node is None:
211
+ return False
212
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
213
+ try:
214
+ parsed = ast.parse(node.value, mode="eval")
215
+ except SyntaxError:
216
+ return False
217
+ return _is_single_value_literal(parsed.body)
218
+ if not isinstance(node, ast.Subscript):
219
+ return False
220
+ if _get_name_flat(node.value).rsplit(".", 1)[-1] != "Literal":
221
+ return False
222
+ slice_node = node.slice
223
+ if type(slice_node).__name__ == "Index":
224
+ slice_node = getattr(slice_node, "value", slice_node)
225
+ if isinstance(slice_node, ast.Tuple):
226
+ return len(slice_node.elts) == 1
227
+ return True
228
+
229
+
193
230
  def _inherits_basemodel(node: ast.ClassDef) -> bool:
194
231
  for base in node.bases:
195
232
  if isinstance(base, ast.Name) and base.id == "BaseModel":