sarj-python-lint 0.4.1__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.1 → sarj_python_lint-0.5.0}/PKG-INFO +2 -1
  2. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/README.md +1 -0
  3. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/__init__.py +2 -0
  5. sarj_python_lint-0.5.0/src/sarj_python_lint/rules/prefer_class_row.py +85 -0
  6. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/.gitignore +0 -0
  7. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/py.typed +0 -0
  10. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rule_base.py +0 -0
  11. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  12. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  13. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  14. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  15. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  16. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  17. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  18. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  19. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/prefer_discriminated_union.py +0 -0
  20. {sarj_python_lint-0.4.1 → sarj_python_lint-0.5.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  21. {sarj_python_lint-0.4.1 → 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.1
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.1"
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" }]
@@ -12,6 +12,7 @@ from sarj_python_lint.rules.no_sequential_await import NoSequentialAwait
12
12
  from sarj_python_lint.rules.no_unreachable_after_terminal import (
13
13
  NoUnreachableAfterTerminal,
14
14
  )
15
+ from sarj_python_lint.rules.prefer_class_row import PreferClassRow
15
16
  from sarj_python_lint.rules.prefer_constant_time_secret_compare import (
16
17
  PreferConstantTimeSecretCompare,
17
18
  )
@@ -24,6 +25,7 @@ REGISTRY: dict[str, type[Rule]] = {
24
25
  NoSequentialAwait.id: NoSequentialAwait,
25
26
  InefficientStringConcatInLoop.id: InefficientStringConcatInLoop,
26
27
  PreferDiscriminatedUnion.id: PreferDiscriminatedUnion,
28
+ PreferClassRow.id: PreferClassRow,
27
29
  PreferStrEnum.id: PreferStrEnum,
28
30
  NoFatTryBlocks.id: NoFatTryBlocks,
29
31
  NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
@@ -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