sarj-python-lint 0.28.0__tar.gz → 0.30.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.
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_logging.py +4 -1
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -10
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +40 -3
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -29
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_implicit_attribute_access.py +52 -1
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +109 -35
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/.gitignore +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/README.md +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_registry.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_patching_system_under_test.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/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.
|
|
3
|
+
Version: 0.30.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
|
|
@@ -12,7 +12,10 @@ import ast
|
|
|
12
12
|
|
|
13
13
|
_LOGGER_NAMES = frozenset({"logger", "log", "logging", "loguru", "_logger", "_log"})
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# Public: `no_fstring_in_log._chain_has_getlogger` must test the SAME set with
|
|
16
|
+
# the SAME casing, or a factory can be a logger to one and not the other.
|
|
17
|
+
LOGGER_FACTORIES = frozenset({"getlogger", "get_logger"})
|
|
18
|
+
_LOGGER_FACTORIES = LOGGER_FACTORIES
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def is_logger_expr(expr: ast.expr) -> bool:
|
|
@@ -57,13 +57,3 @@ def scan_comments(source: str) -> list[Comment]:
|
|
|
57
57
|
)
|
|
58
58
|
for line, col, body, standalone in ordered
|
|
59
59
|
]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def comment_body(raw: str) -> str:
|
|
63
|
-
"""Strip a comment token down to its directive text.
|
|
64
|
-
|
|
65
|
-
Returns:
|
|
66
|
-
The comment text without its leading `#` markers or surrounding space.
|
|
67
|
-
|
|
68
|
-
"""
|
|
69
|
-
return raw.lstrip("#").strip()
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
@@ -76,7 +76,7 @@ from typing import TYPE_CHECKING, override
|
|
|
76
76
|
|
|
77
77
|
from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
|
|
78
78
|
from sarj_python_lint.rules._ast_index import nodes
|
|
79
|
-
from sarj_python_lint.rules._logging import is_logger_expr
|
|
79
|
+
from sarj_python_lint.rules._logging import LOGGER_FACTORIES, is_logger_expr
|
|
80
80
|
|
|
81
81
|
|
|
82
82
|
if TYPE_CHECKING:
|
|
@@ -102,6 +102,11 @@ _LOG_METHODS = frozenset(
|
|
|
102
102
|
# Keyword arguments defined by stdlib `logging` (and never structured fields).
|
|
103
103
|
# Their presence marks the call as a stdlib logger, for which the loguru-style
|
|
104
104
|
# structured-keyword rewrite is wrong.
|
|
105
|
+
# The stdlib factory spelling. `logging.getLogger` is camelCase; structlog uses
|
|
106
|
+
# snake_case `get_logger`, and that difference is the only static signal telling
|
|
107
|
+
# a dotted stdlib factory from a dotted structlog one.
|
|
108
|
+
_STDLIB_FACTORY = "getLogger"
|
|
109
|
+
|
|
105
110
|
_STDLIB_ONLY_KWARGS = frozenset({"exc_info", "stack_info", "extra"})
|
|
106
111
|
|
|
107
112
|
|
|
@@ -272,13 +277,45 @@ def _is_stdlib_logging_call(node: ast.Call, stdlib: _StdlibLoggers) -> bool:
|
|
|
272
277
|
|
|
273
278
|
|
|
274
279
|
def _chain_has_getlogger(expr: ast.expr) -> bool:
|
|
280
|
+
"""Report whether a stdlib `logging` factory appears anywhere in the receiver chain.
|
|
281
|
+
|
|
282
|
+
Matched against the SAME set and casing as `_logging.is_logger_expr`, and that
|
|
283
|
+
parity is load-bearing. When `is_logger_expr` learned to recognise a bare
|
|
284
|
+
`get_logger()` callee, this guard still tested the exact string `"getLogger"`,
|
|
285
|
+
so a snake_case factory returning a *stdlib* logger became a logger to the
|
|
286
|
+
rule but not a stdlib logger to the guard — and the rule then advised
|
|
287
|
+
`logger.info("msg", key=value)` on an API that rejects it.
|
|
288
|
+
|
|
289
|
+
That advice fails in the worst possible way: stdlib `Logger._log` raises
|
|
290
|
+
`TypeError: got an unexpected keyword argument`, but only when the call is
|
|
291
|
+
actually emitted, so the code is green at the default WARNING level and
|
|
292
|
+
breaks the moment log level is raised to INFO. The shim that triggers it —
|
|
293
|
+
`def get_logger(name): return logging.getLogger(name)` — ships in
|
|
294
|
+
huggingface_hub, transformers, fastmcp, mcp and speechmatics, all present in
|
|
295
|
+
consumer virtualenvs.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
True when the chain names a stdlib logging factory.
|
|
299
|
+
|
|
300
|
+
"""
|
|
275
301
|
node = expr
|
|
276
302
|
while True:
|
|
277
303
|
if isinstance(node, ast.Call):
|
|
278
304
|
called = node.func
|
|
279
|
-
|
|
305
|
+
# DOTTED callee: only the stdlib spelling counts. `structlog.get_logger()`
|
|
306
|
+
# must NOT be suppressed — structlog's keyword API is exactly what this
|
|
307
|
+
# rule's advice targets, and it is the rule's main true positive.
|
|
308
|
+
if isinstance(called, ast.Attribute) and called.attr == _STDLIB_FACTORY:
|
|
280
309
|
return True
|
|
281
|
-
|
|
310
|
+
# BARE callee: `get_logger()` and `getLogger()` are both ambiguous — the
|
|
311
|
+
# name alone cannot tell `from structlog import get_logger` from a shim
|
|
312
|
+
# `def get_logger(n): return logging.getLogger(n)`, and such shims ship in
|
|
313
|
+
# huggingface_hub, transformers, fastmcp, mcp and speechmatics. Suppress
|
|
314
|
+
# both, because the failure is asymmetric: a false NEGATIVE costs one
|
|
315
|
+
# style nit, while a false POSITIVE advises `logger.info("m", k=v)` on a
|
|
316
|
+
# stdlib logger, which raises `TypeError` — and only once log level is
|
|
317
|
+
# raised to INFO, so it is green in tests and breaks in production.
|
|
318
|
+
if isinstance(called, ast.Name) and called.id.lower() in LOGGER_FACTORIES:
|
|
282
319
|
return True
|
|
283
320
|
node = called
|
|
284
321
|
elif isinstance(node, ast.Attribute):
|
|
@@ -66,11 +66,6 @@ if TYPE_CHECKING:
|
|
|
66
66
|
|
|
67
67
|
_GEN_RANDOM_UUID_RE = re.compile(r"\bgen_random_uuid\s*\(", re.IGNORECASE)
|
|
68
68
|
|
|
69
|
-
# The whole-source gate in `check`. Deliberately weaker than the pattern above —
|
|
70
|
-
# the identifier alone, no paren — so it cannot exclude a file whose masked SQL
|
|
71
|
-
# would have matched. See the comment at its use site.
|
|
72
|
-
_NAMES_GEN_RANDOM_UUID_RE = re.compile(r"gen_random_uuid", re.IGNORECASE)
|
|
73
|
-
|
|
74
69
|
# A string only counts as SQL when it carries a structural keyword. Without this
|
|
75
70
|
# the rule fires on prose that names the function — including this module's own
|
|
76
71
|
# docstring.
|
|
@@ -116,30 +111,6 @@ class NoGenRandomUuidInSql(Rule):
|
|
|
116
111
|
The diagnostics, sorted by (line, col).
|
|
117
112
|
|
|
118
113
|
"""
|
|
119
|
-
# Naming the function is a NECESSARY condition for any finding: the
|
|
120
|
-
# match runs against `strip_sql_noise(literal)`, and that only ever
|
|
121
|
-
# removes characters, so text absent from the file cannot appear in the
|
|
122
|
-
# masked literal. Checking the raw source first skips the parse and the
|
|
123
|
-
# per-literal SQL masking for the ~100% of files that never mention it.
|
|
124
|
-
#
|
|
125
|
-
# This rule is not store-gated and so masked every string literal in
|
|
126
|
-
# every file, which made it the worst cost-per-finding in the registry
|
|
127
|
-
# (2 findings across 21 corpora, at ~125 ms each). The gate removes that
|
|
128
|
-
# for no loss of coverage — verified by measuring the corpus before and
|
|
129
|
-
# after, not by inspection.
|
|
130
|
-
#
|
|
131
|
-
# The gate deliberately tests only the identifier, NOT the full
|
|
132
|
-
# `_GEN_RANDOM_UUID_RE`, which also requires the open paren. Masking can
|
|
133
|
-
# delete characters *between* the name and its paren — `gen_random_uuid
|
|
134
|
-
# /* c */ ()` masks to `gen_random_uuid ()` and matches — so gating on
|
|
135
|
-
# the stricter pattern would drop a finding the rule would otherwise
|
|
136
|
-
# report. A gate must be strictly weaker than the predicate it guards.
|
|
137
|
-
#
|
|
138
|
-
# It is case-insensitive for the same reason `_GEN_RANDOM_UUID_RE` is: a
|
|
139
|
-
# `DEFAULT GEN_RANDOM_UUID()` in upper-case SQL is a real finding, and a
|
|
140
|
-
# plain `"gen_random_uuid" in source` test would silently drop it.
|
|
141
|
-
if not _NAMES_GEN_RANDOM_UUID_RE.search(source):
|
|
142
|
-
return []
|
|
143
114
|
if is_generated_source(source):
|
|
144
115
|
return []
|
|
145
116
|
tree = parse_or_none(path, source)
|
|
@@ -44,6 +44,34 @@ _EXCLUDED_BASES = {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
# Typing constructs subscripted with string literals. `Literal["x"]` is a type,
|
|
48
|
+
# not a lookup, so the rule's advice ("parse declaratively with Pydantic") is
|
|
49
|
+
# nonsensical there -- the annotation already IS the declarative schema.
|
|
50
|
+
_TYPE_SUBSCRIPTS = frozenset(
|
|
51
|
+
{
|
|
52
|
+
"Literal",
|
|
53
|
+
"Annotated",
|
|
54
|
+
"TypedDict",
|
|
55
|
+
"NamedTuple",
|
|
56
|
+
"Field",
|
|
57
|
+
"Doc",
|
|
58
|
+
"Required",
|
|
59
|
+
"NotRequired",
|
|
60
|
+
"ReadOnly",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _looks_like_route_or_url(value: str) -> bool:
|
|
66
|
+
"""Report whether a `.get()` argument is a route path or URL rather than a key.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
True for `"/users/{id}"`-shaped paths and anything carrying a scheme.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
return value.startswith("/") or "://" in value
|
|
73
|
+
|
|
74
|
+
|
|
47
75
|
def _get_base_name(node: ast.expr) -> str | None:
|
|
48
76
|
if isinstance(node, ast.Name):
|
|
49
77
|
return node.id
|
|
@@ -100,6 +128,14 @@ def _get_key(node: ast.Call) -> str | None:
|
|
|
100
128
|
first = node.args[0]
|
|
101
129
|
if not isinstance(first, ast.Constant) or not isinstance(first.value, str):
|
|
102
130
|
return None
|
|
131
|
+
# `.get()` is also the HTTP verb and the route-registration decorator, and
|
|
132
|
+
# both take a string first argument, so the method name alone cannot tell
|
|
133
|
+
# them from a mapping lookup. The ARGUMENT can: a URL or a route path is not
|
|
134
|
+
# a dictionary key. Measured on bulbul + noura-be, this shape was 168 of the
|
|
135
|
+
# rule's 1,756 findings (9.6%) -- `@router.get("/available-events")` and
|
|
136
|
+
# `await self.http_client.get(url)` were reported as implicit schema access.
|
|
137
|
+
if _looks_like_route_or_url(first.value):
|
|
138
|
+
return None
|
|
103
139
|
return None if _get_base_name(func.value) in _EXCLUDED_BASES else first.value
|
|
104
140
|
|
|
105
141
|
|
|
@@ -111,10 +147,25 @@ def _subscript_key(node: ast.Subscript) -> str | None:
|
|
|
111
147
|
the base is one of the excluded receivers.
|
|
112
148
|
|
|
113
149
|
"""
|
|
150
|
+
# Writing to a mapping is the opposite of the defect. This rule is about
|
|
151
|
+
# PLUCKING fields out of a payload whose schema is already known -- building
|
|
152
|
+
# a dict up key by key (`field_dict["x"] = x`, `params["status"] = ...`) is
|
|
153
|
+
# ordinary construction, and a Pydantic model does not replace it. Measured
|
|
154
|
+
# on bulbul + noura-be this was 503 of 1,756 findings (28.6%), the single
|
|
155
|
+
# largest source, and every sampled instance was an assignment target.
|
|
156
|
+
if isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
157
|
+
return None
|
|
158
|
+
# `Literal["a"]`, `Annotated[T, "..."]` and friends are type expressions that
|
|
159
|
+
# merely LOOK like subscripts. They are not dictionary access at all, and no
|
|
160
|
+
# Pydantic model can replace them -- `Literal["user"]` IS the schema. 470 of
|
|
161
|
+
# 1,756 findings (26.8%) were this, second only to assignment targets.
|
|
162
|
+
base_name = _get_base_name(node.value)
|
|
163
|
+
if base_name in _TYPE_SUBSCRIPTS:
|
|
164
|
+
return None
|
|
114
165
|
index = node.slice
|
|
115
166
|
if not isinstance(index, ast.Constant) or not isinstance(index.value, str):
|
|
116
167
|
return None
|
|
117
|
-
return None if
|
|
168
|
+
return None if base_name in _EXCLUDED_BASES else index.value
|
|
118
169
|
|
|
119
170
|
|
|
120
171
|
def _is_test_path(path: Path) -> bool:
|
|
@@ -1,38 +1,12 @@
|
|
|
1
|
-
"""SARJ080: prefer match/case over sequential
|
|
1
|
+
"""SARJ080: prefer match/case over control-flow try/raise and sequential type guards.
|
|
2
2
|
|
|
3
3
|
Parsers and field deserializers often contain hideous type-dispatch idioms:
|
|
4
|
-
sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
This rule used to carry a second detector for the other half of that idiom —
|
|
12
|
-
raising inside a `try` purely to jump into its own `except` handler, i.e. using
|
|
13
|
-
`raise` as a goto. That detector was removed because ruff already reports it and
|
|
14
|
-
our shipped config already enables it.
|
|
15
|
-
|
|
16
|
-
`ruff.strict.toml` selects `ALL` and does not ignore `TRY`, so `TRY301`
|
|
17
|
-
(`raise-within-try`, "Abstract `raise` to an inner function") is live in every
|
|
18
|
-
consumer. Measured across 21 corpora, the removed arm produced 1,756 findings of
|
|
19
|
-
which 1,649 (93.9%) sat on a line ruff already flagged, and construction
|
|
20
|
-
confirms the columns match exactly, not merely the line:
|
|
21
|
-
|
|
22
|
-
try:
|
|
23
|
-
if not isinstance(x, str):
|
|
24
|
-
raise TypeError # ruff TRY301 at 13:13, old SARJ080 at 13:13
|
|
25
|
-
|
|
26
|
-
The 107 positions TRY301 structurally misses are `raise`s inside an `except`
|
|
27
|
-
body caught by an *outer* `try`. That is a real gap, but not one worth 1,649
|
|
28
|
-
double reports — and 5 of those positions already carry an explicit
|
|
29
|
-
`# noqa: TRY301`, a decision the team made that a second code would quietly
|
|
30
|
-
re-open.
|
|
31
|
-
|
|
32
|
-
The surviving sequential-guard detector has NO ruff counterpart: the same sweep
|
|
33
|
-
found 0 of its 476 positions shared with TRY301, and `RET505` is the closest
|
|
34
|
-
thing ruff has, firing only on `elif`-after-`return` rather than on separate
|
|
35
|
-
`if ...: return` statements.
|
|
4
|
+
sequential `if x is None: return x` / `if isinstance(x, Unset): return x` guards,
|
|
5
|
+
followed by a `try` block containing `if not isinstance(x, T): raise TypeError()`
|
|
6
|
+
to artificially jump control flow into an `except (TypeError, ...): pass` block.
|
|
7
|
+
|
|
8
|
+
Raising an exception inside a `try` block solely to trigger that block's `except`
|
|
9
|
+
handler is using `raise` as a goto (control flow via exceptions).
|
|
36
10
|
|
|
37
11
|
Preferred Python 3.10+ match/case patterns:
|
|
38
12
|
- For `None`: `case None:`
|
|
@@ -68,9 +42,59 @@ if TYPE_CHECKING:
|
|
|
68
42
|
from pathlib import Path
|
|
69
43
|
|
|
70
44
|
|
|
45
|
+
_GENERIC_EXCEPTIONS = frozenset({"Exception", "BaseException"})
|
|
71
46
|
_MIN_SENTINEL_COUNT = 2
|
|
72
47
|
|
|
73
48
|
|
|
49
|
+
def _get_caught_exception_names(handlers: list[ast.ExceptHandler]) -> set[str] | None:
|
|
50
|
+
"""Extract caught exception class names.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A set of exception class names, or None if a catch-all handler (bare `except:`) is present.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
caught: set[str] = set()
|
|
57
|
+
for h in handlers:
|
|
58
|
+
if h.type is None:
|
|
59
|
+
return None
|
|
60
|
+
if isinstance(h.type, ast.Name):
|
|
61
|
+
caught.add(h.type.id)
|
|
62
|
+
elif isinstance(h.type, ast.Tuple):
|
|
63
|
+
for elt in h.type.elts:
|
|
64
|
+
if isinstance(elt, ast.Name):
|
|
65
|
+
caught.add(elt.id)
|
|
66
|
+
elif isinstance(elt, ast.Attribute):
|
|
67
|
+
caught.add(elt.attr)
|
|
68
|
+
caught.add(ast.unparse(elt))
|
|
69
|
+
elif isinstance(h.type, ast.Attribute):
|
|
70
|
+
caught.add(h.type.attr)
|
|
71
|
+
caught.add(ast.unparse(h.type))
|
|
72
|
+
return caught
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _raised_exception_name(raise_node: ast.Raise) -> str | None:
|
|
76
|
+
"""Extract exception class name from `raise Exc()` or `raise Exc`.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The exception class name, or None if no exception is specified.
|
|
80
|
+
|
|
81
|
+
"""
|
|
82
|
+
exc = raise_node.exc
|
|
83
|
+
if exc is None:
|
|
84
|
+
return None
|
|
85
|
+
if isinstance(exc, ast.Call):
|
|
86
|
+
func = exc.func
|
|
87
|
+
if isinstance(func, ast.Name):
|
|
88
|
+
return func.id
|
|
89
|
+
if isinstance(func, ast.Attribute):
|
|
90
|
+
return func.attr
|
|
91
|
+
elif isinstance(exc, ast.Name):
|
|
92
|
+
return exc.id
|
|
93
|
+
elif isinstance(exc, ast.Attribute):
|
|
94
|
+
return exc.attr
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
74
98
|
def _guard_target_var_name(stmt: ast.stmt) -> str | None:
|
|
75
99
|
"""Extract target variable name if statement is `if x is None: return ...` or `if isinstance(x, ...): return ...`.
|
|
76
100
|
|
|
@@ -167,25 +191,75 @@ class _TypeDispatchVisitor(ast.NodeVisitor):
|
|
|
167
191
|
self.path: Path = path
|
|
168
192
|
self.code: str = code
|
|
169
193
|
self.diags: list[Diagnostic] = []
|
|
194
|
+
self.try_stack: list[set[str] | None] = []
|
|
195
|
+
|
|
196
|
+
def visit_Try(self, node: ast.Try | ast.TryStar) -> None:
|
|
197
|
+
caught = _get_caught_exception_names(node.handlers)
|
|
198
|
+
self.try_stack.append(caught)
|
|
199
|
+
for stmt in node.body:
|
|
200
|
+
self.visit(stmt)
|
|
201
|
+
self.try_stack.pop()
|
|
202
|
+
for handler in node.handlers:
|
|
203
|
+
self.visit(handler)
|
|
204
|
+
for stmt in node.orelse:
|
|
205
|
+
self.visit(stmt)
|
|
206
|
+
for stmt in node.finalbody:
|
|
207
|
+
self.visit(stmt)
|
|
208
|
+
|
|
209
|
+
@override
|
|
210
|
+
def visit_TryStar(self, node: ast.TryStar) -> None:
|
|
211
|
+
self.visit_Try(node)
|
|
212
|
+
|
|
213
|
+
def visit_Raise(self, node: ast.Raise) -> None:
|
|
214
|
+
if self.try_stack:
|
|
215
|
+
exc_name = _raised_exception_name(node)
|
|
216
|
+
if exc_name is not None:
|
|
217
|
+
# Search outward through active try scopes
|
|
218
|
+
for caught in reversed(self.try_stack):
|
|
219
|
+
is_caught = caught is None or exc_name in caught or bool(caught & _GENERIC_EXCEPTIONS)
|
|
220
|
+
if is_caught:
|
|
221
|
+
self.diags.append(
|
|
222
|
+
Diagnostic(
|
|
223
|
+
path=self.path,
|
|
224
|
+
line=node.lineno,
|
|
225
|
+
col=node.col_offset + 1,
|
|
226
|
+
code=self.code,
|
|
227
|
+
message=(
|
|
228
|
+
f"Control-flow raise in try block — 'raise {exc_name}()' "
|
|
229
|
+
f"jumps directly to local except handler. Refactor to 'match/case' "
|
|
230
|
+
f"(e.g., 'case str():') to handle types directly."
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
break
|
|
235
|
+
self.generic_visit(node)
|
|
170
236
|
|
|
171
237
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
172
238
|
self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
|
|
239
|
+
saved_stack = self.try_stack
|
|
240
|
+
self.try_stack = []
|
|
173
241
|
self.generic_visit(node)
|
|
242
|
+
self.try_stack = saved_stack
|
|
174
243
|
|
|
175
244
|
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
176
245
|
self.diags.extend(_check_sequential_type_guards(node, self.path, self.code))
|
|
246
|
+
saved_stack = self.try_stack
|
|
247
|
+
self.try_stack = []
|
|
177
248
|
self.generic_visit(node)
|
|
249
|
+
self.try_stack = saved_stack
|
|
178
250
|
|
|
179
251
|
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
180
252
|
self.generic_visit(node)
|
|
181
253
|
|
|
182
254
|
|
|
183
255
|
class PreferMatchTypeDispatch(Rule):
|
|
184
|
-
"""Prefer match/case over sequential
|
|
256
|
+
"""Prefer match/case over try/raise control flow and sequential type guards."""
|
|
185
257
|
|
|
186
258
|
id: str = "prefer-match-type-dispatch"
|
|
187
259
|
code: str = "SARJ080"
|
|
188
|
-
description: str =
|
|
260
|
+
description: str = (
|
|
261
|
+
"Control-flow raise in try block or sequential type guards — prefer Python 3.10+ match/case pattern matching."
|
|
262
|
+
)
|
|
189
263
|
|
|
190
264
|
@override
|
|
191
265
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/no_stdlib_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_or_pattern.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.28.0 → sarj_python_lint-0.30.0}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|