sarj-python-lint 0.18.0__tar.gz → 0.18.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 (54) hide show
  1. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rule_base.py +5 -5
  4. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +2 -0
  5. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/kwonly_same_type_params.py +34 -1
  6. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_sequential_await.py +2 -0
  7. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_str_enum.py +18 -0
  8. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +21 -0
  9. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/stepdown.py +2 -0
  10. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/.gitignore +0 -0
  11. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/README.md +0 -0
  12. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/__init__.py +0 -0
  13. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/__main__.py +0 -0
  14. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/__init__.py +0 -0
  18. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_logging.py +0 -0
  19. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_paths.py +0 -0
  20. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_registry.py +0 -0
  21. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/_sql.py +0 -0
  22. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  23. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  24. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  25. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  26. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  27. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  28. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  29. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  30. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  31. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  32. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  33. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  34. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  35. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  36. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  37. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  38. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  39. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  40. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  41. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  42. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  43. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  44. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  45. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  46. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  47. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  48. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  49. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  50. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  51. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  52. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  53. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  54. {sarj_python_lint-0.18.0 → sarj_python_lint-0.18.1}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.18.0
3
+ Version: 0.18.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.18.0"
3
+ version = "0.18.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" }]
@@ -87,7 +87,7 @@ class Rule(ABC):
87
87
  raise NotImplementedError
88
88
 
89
89
 
90
- _last_parse: tuple[tuple[str, int, int], ast.Module | None] | None = None
90
+ _last_parse: tuple[str, str, ast.Module | None] | None = None
91
91
 
92
92
 
93
93
  def parse_or_none(path: Path, source: str) -> ast.Module | None:
@@ -98,12 +98,12 @@ def parse_or_none(path: Path, source: str) -> ast.Module | None:
98
98
 
99
99
  """
100
100
  global _last_parse # ruff:ignore[global-statement] — single-slot memo; the CLI runs rules per file sequentially
101
- key = (str(path), len(source), hash(source))
102
- if _last_parse is not None and _last_parse[0] == key:
103
- return _last_parse[1]
101
+ path_key = str(path)
102
+ if _last_parse is not None and _last_parse[0] == path_key and _last_parse[1] is source:
103
+ return _last_parse[2]
104
104
  try:
105
105
  tree = ast.parse(source, filename=str(path))
106
106
  except SyntaxError:
107
107
  tree = None
108
- _last_parse = (key, tree)
108
+ _last_parse = (path_key, source, tree)
109
109
  return tree
@@ -67,6 +67,8 @@ class InefficientStringConcatInLoop(Rule):
67
67
  def check(self, path: Path, source: str) -> list[Diagnostic]:
68
68
  if is_generated_source(source):
69
69
  return []
70
+ if "+" not in source or ("for " not in source and "while " not in source):
71
+ return []
70
72
  tree = parse_or_none(path, source)
71
73
  if tree is None:
72
74
  return []
@@ -157,16 +157,30 @@ _DUCK_PROTOCOL_METHODS = frozenset(
157
157
  #: drawn entirely from one of these reads unambiguously positionally.
158
158
  _CONVENTIONAL_ORDER_GROUPS = (
159
159
  frozenset({"x", "y", "z"}),
160
+ frozenset({"lat", "lon", "alt"}),
161
+ frozenset({"latitude", "longitude", "altitude"}),
160
162
  frozenset({"width", "height", "depth"}),
161
163
  frozenset({"red", "green", "blue", "alpha"}),
162
164
  frozenset({"row", "column"}),
163
165
  frozenset({"top", "right", "bottom", "left"}),
166
+ frozenset({"left", "right"}),
167
+ frozenset({"lo", "hi"}),
168
+ frozenset({"low", "high"}),
169
+ frozenset({"minimum", "maximum"}),
170
+ frozenset({"min_value", "max_value"}),
171
+ frozenset({"begin", "end"}),
172
+ frozenset({"source", "sink"}),
164
173
  frozenset({"year", "month", "day"}),
165
174
  frozenset({"hour", "minute", "second", "microsecond"}),
166
175
  frozenset({"start", "stop", "step"}),
167
176
  )
168
177
 
169
178
  _EXEMPT_NAME_PREFIXES = ("visit_", "test_")
179
+ _RISKY_NAME_PART_RE = re.compile(
180
+ r"(?:^|_)(?:id|key|token|secret|password|signature|hash|email|url|uri|path|file|"
181
+ r"source|src|target|dst|dest|destination|parent|child|from|to|old|new|"
182
+ r"before|after|previous|next|expected|actual|left_id|right_id)(?:_|$)"
183
+ )
170
184
 
171
185
 
172
186
  class KwonlySameTypeParams(Rule):
@@ -350,11 +364,30 @@ def _swap_prone_annotation(args: ast.arguments) -> str | None:
350
364
  for name, arg_names in sorted(groups.items(), key=lambda kv: -len(kv[1])):
351
365
  if len(arg_names) >= _MIN_SAME_TYPE and not (
352
366
  _is_symmetric_numbering(arg_names) or _is_conventional_order(arg_names)
353
- ):
367
+ ) and _is_high_value_group(name, arg_names):
354
368
  return name
355
369
  return None
356
370
 
357
371
 
372
+ def _is_high_value_group(annotation: str, arg_names: list[str]) -> bool:
373
+ """Report whether a same-primitive group is worth enforcing globally.
374
+
375
+ Booleans are always high-risk because positional `True, False` carries no
376
+ call-site meaning. Other primitives fire only when the parameter names carry
377
+ production-domain identifiers or directed relationships (`source_id`,
378
+ `target_id`, `old_key`, `new_key`, `input_path`, `output_path`). This keeps
379
+ math / algorithm APIs such as `power(base, exponent)` and `f(a, b)` out of
380
+ the default rule while preserving the bug class the rule was written for.
381
+
382
+ Returns:
383
+ True when the group should be reported.
384
+
385
+ """
386
+ if annotation == "bool":
387
+ return True
388
+ return sum(1 for name in arg_names if _RISKY_NAME_PART_RE.search(name)) >= _MIN_SAME_TYPE
389
+
390
+
358
391
  def _is_dunder_prefixed(arg: str) -> bool:
359
392
  """Report whether `arg` uses the PEP 484 positional-only naming convention.
360
393
 
@@ -76,6 +76,8 @@ class NoSequentialAwait(Rule):
76
76
  def check(self, path: Path, source: str) -> list[Diagnostic]:
77
77
  if _is_test_path(path):
78
78
  return []
79
+ if "await" not in source or "for " not in source:
80
+ return []
79
81
  tree = parse_or_none(path, source)
80
82
  if tree is None:
81
83
  return []
@@ -235,6 +235,8 @@ class PreferStrEnum(Rule):
235
235
 
236
236
  @override
237
237
  def check(self, path: Path, source: str) -> list[Diagnostic]:
238
+ if not _has_str_enum_signal(source):
239
+ return []
238
240
  tree = parse_or_none(path, source)
239
241
  if tree is None:
240
242
  return []
@@ -332,6 +334,22 @@ class PreferStrEnum(Rule):
332
334
  return diags
333
335
 
334
336
 
337
+ def _has_str_enum_signal(source: str) -> bool:
338
+ """Cheap source gate for files that cannot contain this rule's triggers.
339
+
340
+ Returns:
341
+ True when the source contains enough lexical signal to justify parsing.
342
+
343
+ """
344
+ has_string_literal = '"' in source or "'" in source
345
+ if "str" in source and any(name in source.lower() for name in CHOICES_ATTR_NAMES):
346
+ return True
347
+ return (
348
+ has_string_literal
349
+ and ("==" in source or "!=" in source or "case " in source or "match " in source)
350
+ )
351
+
352
+
335
353
  def _cluster_fires(key: str, entry: _ClusterEntry) -> bool:
336
354
  _line, _col, literals, eq_literals, ne_literals, in_literals = entry
337
355
  if not eq_literals and not ne_literals:
@@ -110,6 +110,25 @@ _EXCLUDE_RE = re.compile(
110
110
  )
111
111
 
112
112
  _NUMERIC_NAMES = frozenset({"int", "float"})
113
+ _BARE_UNIT_NAMES = frozenset(
114
+ {
115
+ "day",
116
+ "days",
117
+ "hour",
118
+ "hours",
119
+ "minute",
120
+ "minutes",
121
+ "min",
122
+ "mins",
123
+ "second",
124
+ "seconds",
125
+ "sec",
126
+ "secs",
127
+ "millisecond",
128
+ "milliseconds",
129
+ "ms",
130
+ }
131
+ )
113
132
 
114
133
  #: Roots of the CLI frameworks whose decorators bind a parameter to an argv value.
115
134
  _CLI_MODULES = frozenset({"click", "typer"})
@@ -180,6 +199,8 @@ class PreferTimedeltaForDurations(Rule):
180
199
  ) -> None:
181
200
  if annotation is None:
182
201
  return
202
+ if name.lower() in _BARE_UNIT_NAMES or name.lower().endswith(("_worked", "_elapsed")):
203
+ return
183
204
  if not _UNIT_RE.search(name) or _EXCLUDE_RE.search(name):
184
205
  return
185
206
  numeric = _numeric_annotation(annotation)
@@ -115,6 +115,8 @@ class Stepdown(Rule):
115
115
  return []
116
116
  if _is_test_path(path):
117
117
  return []
118
+ if "def _" not in source and "async def _" not in source:
119
+ return []
118
120
  tree = parse_or_none(path, source)
119
121
  if tree is None:
120
122
  return []