sarj-python-lint 0.3.0__tar.gz → 0.4.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 (19) hide show
  1. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/prefer_str_enum.py +7 -15
  4. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +33 -24
  5. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/.gitignore +0 -0
  6. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/README.md +0 -0
  7. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/py.typed +0 -0
  10. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rule_base.py +0 -0
  11. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  12. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  13. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  14. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  15. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  16. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  17. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  18. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  19. {sarj_python_lint-0.3.0 → sarj_python_lint-0.4.0}/src/sarj_python_lint/rules/prefer_discriminated_union.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.3.0"
3
+ version = "0.4.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" }]
@@ -40,30 +40,22 @@ from pathlib import Path
40
40
  from sarj_python_lint.rule_base import Diagnostic, Rule
41
41
 
42
42
  #: Field / variable name tokens that strongly suggest a closed enumeration.
43
+ #: Kept deliberately HIGH-PRECISION — only words that are almost always a fixed
44
+ #: set. Broader/free-form-prone tokens (type, provider, level, mode, category,
45
+ #: channel, method, strategy, format, source, language, environment, …) were
46
+ #: removed: they over-fired on free-form strings. Those cases are still caught
47
+ #: when corroborated — via a sibling `choices`/`states` attribute or a
48
+ #: comparison cluster against literal values.
43
49
  CHOICE_NAME_TOKENS = frozenset(
44
50
  {
45
51
  "status",
46
52
  "state",
47
- "type",
48
53
  "kind",
49
- "provider",
50
- "language",
51
- "lang",
52
54
  "role",
53
55
  "priority",
54
- "level",
55
- "mode",
56
- "category",
56
+ "severity",
57
57
  "direction",
58
- "environment",
59
- "env",
60
58
  "tier",
61
- "severity",
62
- "channel",
63
- "method",
64
- "strategy",
65
- "format",
66
- "source",
67
59
  "stage",
68
60
  }
69
61
  )
@@ -27,15 +27,16 @@ Purely annotation-based (no type inference), checked on function definitions
27
27
  (sync + async):
28
28
 
29
29
  1. Return annotation that is ``dict[str, Any]`` / ``dict[str, object]`` /
30
- bare ``dict`` / ``Dict``, ``list[dict[str, Any]]``, or a ``tuple[...]``
31
- with 2+ distinct element types.
30
+ bare ``dict`` / ``Dict``, or ``list[dict[str, Any]]``.
32
31
  2. FastAPI route handlers (``@router.get(...)`` / ``@app.post(...)`` etc.)
33
32
  with no return annotation and no ``response_model=`` in the decorator.
34
33
 
35
- Not flagged: fully-concrete dict value types (``dict[str, str]``),
36
- homogeneous tuples (``tuple[int, ...]``, ``tuple[str, str]``), heterogeneous
37
- tuple returns from private (``_``-prefixed) non-route functions, ``@overload``
38
- stubs, and test files.
34
+ Deliberately NOT flagged (kept high-precision for real boundaries):
35
+ private / ``_``-prefixed functions (internal, not a public contract), pydantic
36
+ ``@model_validator`` / ``@field_validator`` hooks (raw dict in/out is their
37
+ API), ``tuple[...]`` returns (multiple return values are idiomatic Python),
38
+ fully-concrete dict value types (``dict[str, str]``), ``@overload`` stubs, and
39
+ test files.
39
40
 
40
41
  References:
41
42
  - https://docs.pydantic.dev/latest/concepts/models/
@@ -53,7 +54,6 @@ from sarj_python_lint.rule_base import Diagnostic, Rule
53
54
  _HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
54
55
  _DICT_NAMES = {"dict", "Dict"}
55
56
  _LIST_NAMES = {"list", "List"}
56
- _TUPLE_NAMES = {"tuple", "Tuple"}
57
57
  _ANY_VALUE_NAMES = {"Any", "object"}
58
58
 
59
59
 
@@ -65,12 +65,12 @@ class _RouteInfo:
65
65
 
66
66
 
67
67
  class PydanticAtBoundaries(Rule):
68
- """Untyped dict / heterogeneous tuple return — define a pydantic model."""
68
+ """Untyped dict return at a public boundary — define a pydantic model."""
69
69
 
70
70
  id = "pydantic-at-boundaries"
71
71
  code = "SARJ008"
72
72
  description = (
73
- "Function returns an untyped dict or heterogeneous tuple — "
73
+ "Public function/route returns an untyped dict — "
74
74
  "define a pydantic model (or frozen dataclass)."
75
75
  )
76
76
 
@@ -87,6 +87,15 @@ class PydanticAtBoundaries(Rule):
87
87
  continue
88
88
  if _is_overload(node):
89
89
  continue
90
+ # Private/internal functions are not public boundaries — their
91
+ # return shape is an implementation detail, not a data contract.
92
+ if node.name.startswith("_"):
93
+ continue
94
+ # Pydantic validator hooks (`@model_validator`/`@field_validator`)
95
+ # take and return raw dict/values by contract — that's the API, not
96
+ # a missing model.
97
+ if _is_validator(node):
98
+ continue
90
99
  route = _route_info(node)
91
100
  returns = _resolve_annotation(node.returns)
92
101
  if returns is None:
@@ -108,10 +117,6 @@ class PydanticAtBoundaries(Rule):
108
117
  kind = _classify_return(returns)
109
118
  if kind is None:
110
119
  continue
111
- # Private functions returning heterogeneous tuples are common and
112
- # fine-ish; untyped dicts are flagged everywhere.
113
- if kind == "tuple" and route is None and node.name.startswith("_"):
114
- continue
115
120
  ann_text = ast.unparse(returns)
116
121
  diags.append(
117
122
  Diagnostic(
@@ -141,6 +146,19 @@ def _is_overload(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
141
146
  return False
142
147
 
143
148
 
149
+ _VALIDATOR_DECORATORS = {"model_validator", "field_validator", "validator", "root_validator"}
150
+
151
+
152
+ def _is_validator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
153
+ """A pydantic validator hook — dict/value in-and-out is its required contract."""
154
+ for dec in node.decorator_list:
155
+ target = dec.func if isinstance(dec, ast.Call) else dec
156
+ name = _flat_name(target) if isinstance(target, (ast.Name, ast.Attribute)) else ""
157
+ if name in _VALIDATOR_DECORATORS:
158
+ return True
159
+ return False
160
+
161
+
144
162
  def _route_info(node: ast.FunctionDef | ast.AsyncFunctionDef) -> _RouteInfo | None:
145
163
  """Detect a FastAPI route decorator: `@<router|app|*_router>.<method>(...)`."""
146
164
  for dec in node.decorator_list:
@@ -199,8 +217,8 @@ def _classify_return(node: ast.expr) -> str | None:
199
217
  return "dict" if inner == "dict" else None
200
218
  if base in _DICT_NAMES:
201
219
  return "dict" if _is_untyped_dict_args(node.slice) else None
202
- if base in _TUPLE_NAMES:
203
- return "tuple" if _is_heterogeneous_tuple_args(node.slice) else None
220
+ # Heterogeneous tuple returns are NOT flagged — multiple return values are
221
+ # idiomatic Python, not a missing data contract.
204
222
  return None
205
223
 
206
224
 
@@ -211,15 +229,6 @@ def _is_untyped_dict_args(slice_node: ast.expr) -> bool:
211
229
  return _flat_name(slice_node.elts[1]) in _ANY_VALUE_NAMES
212
230
 
213
231
 
214
- def _is_heterogeneous_tuple_args(slice_node: ast.expr) -> bool:
215
- """`tuple[...]` is flagged when it has 2+ distinct element types."""
216
- if not isinstance(slice_node, ast.Tuple):
217
- return False # single-element `tuple[X]`
218
- # `tuple[X, ...]` is a homogeneous variadic tuple.
219
- if any(isinstance(elt, ast.Constant) and elt.value is Ellipsis for elt in slice_node.elts):
220
- return False
221
- distinct = {ast.unparse(elt) for elt in slice_node.elts}
222
- return len(distinct) >= 2
223
232
 
224
233
 
225
234
  def _flat_name(node: ast.expr) -> str: