sarj-python-lint 0.36.1__tar.gz → 0.37.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 (95) hide show
  1. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_registry.py +0 -2
  4. sarj_python_lint-0.36.1/src/sarj_python_lint/rules/no_implicit_attribute_access.py +0 -525
  5. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/.gitignore +0 -0
  6. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/README.md +0 -0
  7. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  10. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/_secret_names.py +0 -0
  11. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/_version.py +0 -0
  12. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/py.typed +0 -0
  13. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/ratchet.py +0 -0
  14. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rule_base.py +0 -0
  15. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  16. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  17. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  18. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  19. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  20. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  21. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  22. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  23. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  24. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  25. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  26. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  27. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  28. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  29. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  30. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  31. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  32. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  33. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  34. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  35. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  36. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  37. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  38. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  39. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  40. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  41. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  42. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  43. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  44. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  45. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  46. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  47. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  48. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  49. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  50. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  51. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  52. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  53. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  54. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  55. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  56. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  57. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  58. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  59. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  60. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  61. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  62. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  63. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  64. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  65. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  66. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  67. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  68. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  69. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  70. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  71. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  72. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  73. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  74. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  75. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  76. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  77. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  78. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  79. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  80. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  81. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  82. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  83. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  84. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  85. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  86. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  87. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  88. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  89. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  90. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  91. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  92. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  93. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  94. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  95. {sarj_python_lint-0.36.1 → sarj_python_lint-0.37.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.36.1
3
+ Version: 0.37.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.36.1"
3
+ version = "0.37.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" }]
@@ -36,7 +36,6 @@ from sarj_python_lint.rules.no_first_party_private_import import (
36
36
  )
37
37
  from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
38
38
  from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
39
- from sarj_python_lint.rules.no_implicit_attribute_access import NoImplicitAttributeAccess
40
39
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
41
40
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
42
41
  from sarj_python_lint.rules.no_optional_tenant_predicate import (
@@ -122,7 +121,6 @@ REGISTRY: dict[str, type[Rule]] = {
122
121
  PreferStrEnum.id: PreferStrEnum,
123
122
  NoFatTryBlocks.id: NoFatTryBlocks,
124
123
  NoIsinstanceUnionChain.id: NoIsinstanceUnionChain,
125
- NoImplicitAttributeAccess.id: NoImplicitAttributeAccess,
126
124
  NoOffsetPagination.id: NoOffsetPagination,
127
125
  PreferNamedtupleOverTupleReturn.id: PreferNamedtupleOverTupleReturn,
128
126
  NoCorsWildcardWithCredentials.id: NoCorsWildcardWithCredentials,
@@ -1,525 +0,0 @@
1
- """SARJ083 — Forbid implicit dictionary accesses using string literals.
2
-
3
- Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_implicit_attribute_access.py
4
- Evidence: https://github.com/sarj-ai/standards/blob/main/docs/rules/SARJ083.md
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import ast
10
- from dataclasses import dataclass
11
- from typing import TYPE_CHECKING, override
12
-
13
- from sarj_python_lint.rule_base import Diagnostic, Rule, parse_or_none
14
- from sarj_python_lint.rules._ast_index import nodes, walk
15
-
16
-
17
- if TYPE_CHECKING:
18
- from pathlib import Path
19
-
20
- _EXCLUDED_BASES = {
21
- "environ",
22
- "headers",
23
- "cookies",
24
- "session",
25
- "redis",
26
- "cache",
27
- "state",
28
- "config",
29
- "kwargs",
30
- "env",
31
- # A deliberately OPEN extension bag: the framework guarantees the mapping
32
- # exists and guarantees nothing about its keys, because third-party code is
33
- # what puts them there. `scrapy/core/downloader/handlers/http11.py:531`
34
- # (`request.meta.get("download_maxsize", self._maxsize)`) is the shape --
35
- # Scrapy documents `Request.meta` as the per-request extension dict, and no
36
- # model can enumerate keys that downstream middlewares invent.
37
- "meta",
38
- "os",
39
- "sys",
40
- }
41
-
42
-
43
- # Typing constructs subscripted with string literals. `Literal["x"]` is a type,
44
- # not a lookup, so the rule's advice ("parse declaratively with Pydantic") is
45
- # nonsensical there -- the annotation already IS the declarative schema.
46
- _TYPE_SUBSCRIPTS = frozenset(
47
- {
48
- "Literal",
49
- "Annotated",
50
- "TypedDict",
51
- "NamedTuple",
52
- "Field",
53
- "Doc",
54
- "Required",
55
- "NotRequired",
56
- "ReadOnly",
57
- }
58
- )
59
-
60
- # Generic wrappers that do not change what the annotated value IS. `Optional[X]`,
61
- # `Final[X]` and `Annotated[X, ...]` all still describe an `X`, so the search for
62
- # a TypedDict has to pass through them; `list[X]` does not, and is not here.
63
- _TYPE_WRAPPERS = frozenset(
64
- {
65
- "Optional",
66
- "Union",
67
- "Annotated",
68
- "Final",
69
- "ClassVar",
70
- "Required",
71
- "NotRequired",
72
- "ReadOnly",
73
- }
74
- )
75
-
76
- # In-place collection methods. A subscript that is one of these calls' receiver
77
- # is building the collection it indexes, not reading a field out of a payload.
78
- _MUTATION_METHODS = frozenset({"append", "add", "extend", "update", "insert", "discard", "setdefault"})
79
-
80
- # Namespaces the language itself defines. Their keys are CPython's, not a
81
- # schema anybody could have declared.
82
- _REFLECTION_BASES = frozenset({"f_globals", "f_locals", "__annotations__"})
83
- _REFLECTION_CALLS = frozenset({"globals", "locals", "get_type_hints"})
84
-
85
- # `ConfigParser.get(section, option, *, fallback=...)`. `dict.get` has no
86
- # `fallback` parameter, so the keyword alone identifies the call exactly.
87
- _CONFIGPARSER_POSITIONALS = 2
88
-
89
-
90
- def _looks_like_route_or_url(value: str) -> bool:
91
- """Report whether a `.get()` argument is a route path or URL rather than a key."""
92
- return value.startswith("/") or "://" in value
93
-
94
-
95
- def _get_base_name(node: ast.expr) -> str | None:
96
- if isinstance(node, ast.Name):
97
- return node.id
98
- if isinstance(node, ast.Attribute):
99
- return node.attr
100
- return None
101
-
102
-
103
- def _root_name(node: ast.expr) -> str | None:
104
- """Walk an attribute/subscript spine down to the identifier it starts at.
105
-
106
- Returns:
107
- The leftmost `Name`'s identifier, or None when the spine has no root name.
108
-
109
- """
110
- while isinstance(node, (ast.Attribute, ast.Subscript)):
111
- node = node.value
112
- return node.id if isinstance(node, ast.Name) else None
113
-
114
-
115
- def _is_dunder(key: str) -> bool:
116
- return key.startswith("__") and key.endswith("__") and len(key) > len("____")
117
-
118
-
119
- @dataclass(frozen=True, slots=True)
120
- class _FileFacts:
121
- """The whole-file context a single subscript cannot answer for itself."""
122
-
123
- annotation_nodes: frozenset[int]
124
- decorator_nodes: frozenset[int]
125
- mutation_receivers: frozenset[int]
126
- schema_bound_names: frozenset[str]
127
- constant_tables: frozenset[str]
128
-
129
-
130
- class NoImplicitAttributeAccess(Rule):
131
- id: str = "no-implicit-attribute-access"
132
- code: str = "SARJ083"
133
- has_evidence: bool = True
134
- description: str = "Implicit dictionary access with string literals — parse declaratively with Pydantic."
135
-
136
- @override
137
- def check(self, path: Path, source: str) -> list[Diagnostic]:
138
- if _is_test_path(path) or _is_excluded_path(path):
139
- return []
140
- tree = parse_or_none(path, source)
141
- if tree is None:
142
- return []
143
-
144
- candidates: list[tuple[ast.Call | ast.Subscript, str]] = []
145
- for node in nodes(tree, ast.Call, ast.Subscript):
146
- key = _get_key(node) if isinstance(node, ast.Call) else _subscript_key(node)
147
- if key is not None:
148
- candidates.append((node, key))
149
- if not candidates:
150
- # The whole-file index below is only ever needed to *reject*, so a
151
- # file with nothing to reject must not pay for it.
152
- return []
153
-
154
- facts = _file_facts(tree)
155
- diags: list[Diagnostic] = []
156
- for node, key in candidates:
157
- if _is_exempt(node, key, facts):
158
- continue
159
- lookup = f".get('{key}')" if isinstance(node, ast.Call) else f"['{key}']"
160
- diags.append(
161
- Diagnostic(
162
- path=path,
163
- line=node.lineno,
164
- col=node.col_offset + 1,
165
- code=self.code,
166
- message=f"Imperative `{lookup}` lookup — use a declarative Pydantic model instead.",
167
- )
168
- )
169
-
170
- return diags
171
-
172
-
173
- def _get_key(node: ast.Call) -> str | None:
174
- """Read the string key of a `<base>.get("literal")` lookup worth reporting."""
175
- func = node.func
176
- if not isinstance(func, ast.Attribute) or func.attr != "get" or not node.args:
177
- return None
178
- first = node.args[0]
179
- if not isinstance(first, ast.Constant) or not isinstance(first.value, str):
180
- return None
181
- # `.get()` is also the HTTP verb and the route-registration decorator, and
182
- # both take a string first argument, so the method name alone cannot tell
183
- # them from a mapping lookup. The ARGUMENT can: a URL or a route path is not
184
- # a dictionary key. Measured on two first-party repos, this shape was 168 of the
185
- # rule's 1,756 findings (9.6%) -- `@router.get("/available-events")` and
186
- # `await self.http_client.get(url)` were reported as implicit schema access.
187
- if _looks_like_route_or_url(first.value):
188
- return None
189
- if _is_configparser_get(node):
190
- return None
191
- return None if _get_base_name(func.value) in _EXCLUDED_BASES else first.value
192
-
193
-
194
- def _is_configparser_get(node: ast.Call) -> bool:
195
- """Report whether a `.get(...)` call is `ConfigParser.get(section, option)`.
196
-
197
- `conf.get("api", "ssl_cert", fallback="")` reads an INI file by section and
198
- option; the two string arguments are not a key and a default. `dict.get`
199
- accepts no `fallback` keyword at all, so its presence alongside two
200
- positional strings identifies the configparser signature exactly and the
201
- guard costs no recall.
202
-
203
- Returns:
204
- True when the call carries `fallback=` and two positional string arguments.
205
-
206
- """
207
- if not any(kw.arg == "fallback" for kw in node.keywords):
208
- return False
209
- if len(node.args) < _CONFIGPARSER_POSITIONALS:
210
- return False
211
- return all(
212
- isinstance(arg, ast.Constant) and isinstance(arg.value, str) for arg in node.args[:_CONFIGPARSER_POSITIONALS]
213
- )
214
-
215
-
216
- def _subscript_key(node: ast.Subscript) -> str | None:
217
- """Read the string key of a `<base>["literal"]` lookup worth reporting."""
218
- # Writing to a mapping is the opposite of the defect. This rule is about
219
- # PLUCKING fields out of a payload whose schema is already known -- building
220
- # a dict up key by key (`field_dict["x"] = x`, `params["status"] = ...`) is
221
- # ordinary construction, and a Pydantic model does not replace it. Measured
222
- # on two first-party repos this was 503 of 1,756 findings (28.6%), the single
223
- # largest source, and every sampled instance was an assignment target. A
224
- # `d["k"] += 1` target is a `Store` too, so augmented assignment lands here.
225
- if isinstance(node.ctx, (ast.Store, ast.Del)):
226
- return None
227
- # `Literal["a"]`, `Annotated[T, "..."]` and friends are type expressions that
228
- # merely LOOK like subscripts. They are not dictionary access at all, and no
229
- # Pydantic model can replace them -- `Literal["user"]` IS the schema. 470 of
230
- # 1,756 findings (26.8%), second only to assignment targets. The positional
231
- # annotation guard in `_is_exempt` covers the rest of the same family, where
232
- # the wrapper is an ordinary generic (`Optional["Router"]`).
233
- base_name = _get_base_name(node.value)
234
- if base_name in _TYPE_SUBSCRIPTS:
235
- return None
236
- index = node.slice
237
- if not isinstance(index, ast.Constant) or not isinstance(index.value, str):
238
- return None
239
- return None if base_name in _EXCLUDED_BASES else index.value
240
-
241
-
242
- def _is_exempt(node: ast.Call | ast.Subscript, key: str, facts: _FileFacts) -> bool:
243
- """Report whether whole-file context clears a lookup the local test flagged.
244
-
245
- Returns:
246
- True when the lookup is one of the five measured non-defect shapes.
247
-
248
- """
249
- if _is_dunder(key):
250
- return True
251
- if id(node) in facts.annotation_nodes:
252
- return True
253
- if id(node) in facts.decorator_nodes:
254
- return True
255
- if isinstance(node, ast.Subscript) and id(node) in facts.mutation_receivers:
256
- return True
257
- receiver = _receiver(node)
258
- if receiver is None:
259
- return False
260
- if _get_base_name(receiver) in _REFLECTION_BASES:
261
- return True
262
- if isinstance(receiver, ast.Call) and _get_base_name(receiver.func) in _REFLECTION_CALLS:
263
- return True
264
- root = _root_name(receiver)
265
- return root is not None and (root in facts.schema_bound_names or root in facts.constant_tables)
266
-
267
-
268
- def _receiver(node: ast.Call | ast.Subscript) -> ast.expr | None:
269
- """Read the expression a lookup is performed ON.
270
-
271
- Returns:
272
- `x` for `x["k"]` and for `x.get("k")`, or None when the shape is neither.
273
-
274
- """
275
- match node:
276
- case ast.Subscript(value=value):
277
- return value
278
- case ast.Call(func=ast.Attribute(value=value)):
279
- return value
280
- case _:
281
- return None
282
-
283
-
284
- def _file_facts(tree: ast.Module) -> _FileFacts:
285
- """Derive the whole-file context the per-node guards consult.
286
-
287
- Returns:
288
- The four indexes, each built from the memoized per-file node index.
289
-
290
- """
291
- typed_dicts = _typed_dict_class_names(tree) | _declared_type_names(tree)
292
- return _FileFacts(
293
- annotation_nodes=_annotation_nodes(tree),
294
- decorator_nodes=_decorator_nodes(tree),
295
- mutation_receivers=_mutation_receivers(tree),
296
- schema_bound_names=_schema_bound_names(tree, typed_dicts) if typed_dicts else frozenset(),
297
- constant_tables=_constant_tables(tree),
298
- )
299
-
300
-
301
- def _annotation_nodes(tree: ast.Module) -> frozenset[int]:
302
- """Collect the identity of every node sitting inside an annotation.
303
-
304
- Returns:
305
- `id()` of each node in a parameter, return or variable annotation subtree.
306
-
307
- """
308
- roots: list[ast.expr] = []
309
- for node in nodes(tree, ast.arg, ast.AnnAssign, ast.FunctionDef, ast.AsyncFunctionDef):
310
- match node:
311
- case ast.arg(annotation=ast.expr() as annotation):
312
- roots.append(annotation)
313
- case ast.AnnAssign(annotation=annotation):
314
- roots.append(annotation)
315
- case ast.FunctionDef(returns=ast.expr() as returns) | ast.AsyncFunctionDef(returns=ast.expr() as returns):
316
- roots.append(returns)
317
- case _:
318
- pass
319
- return frozenset(id(inner) for root in roots for inner in walk(root))
320
-
321
-
322
- def _decorator_nodes(tree: ast.Module) -> frozenset[int]:
323
- """Collect the identity of every node inside a decorator expression.
324
-
325
- A decorator is never a mapping lookup, so `@router.get("")` is not one --
326
- but `_looks_like_route_or_url` only recognises a value starting with `/` or
327
- containing `://`, and the EMPTY-STRING route (the router-root registration
328
- FastAPI projects write) slips through both. 23 findings across airflow,
329
- litellm and prefect were exactly `@<router>.get("")` on the decorator line.
330
- Position answers it exactly where the argument cannot, and costs no recall:
331
- nothing in `decorator_list` is a payload field read.
332
-
333
- Returns:
334
- `id()` of each node in a decorator subtree.
335
-
336
- """
337
- roots: list[ast.expr] = []
338
- for node in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef):
339
- roots.extend(node.decorator_list)
340
- return frozenset(id(inner) for root in roots for inner in walk(root))
341
-
342
-
343
- def _mutation_receivers(tree: ast.Module) -> frozenset[int]:
344
- """Collect subscripts that are the receiver of an in-place collection method.
345
-
346
- Returns:
347
- `id()` of each `<sub>["k"]` that `.append`/`.update`/… is called on.
348
-
349
- """
350
- receivers: set[int] = set()
351
- for call in nodes(tree, ast.Call):
352
- func = call.func
353
- if isinstance(func, ast.Attribute) and func.attr in _MUTATION_METHODS:
354
- receivers.add(id(func.value))
355
- return frozenset(receivers)
356
-
357
-
358
- def _typed_dict_class_names(tree: ast.Module) -> frozenset[str]:
359
- """Collect the names of TypedDict types declared in this file.
360
-
361
- Both spellings are read — `class X(TypedDict)` and the functional
362
- `X = TypedDict("X", {...})` — and subclassing is followed to a fixed point,
363
- so a `class Y(X)` under a TypedDict `X` counts too.
364
-
365
- Returns:
366
- The TypedDict type names declared in this module.
367
-
368
- """
369
- declared: set[str] = set()
370
- derived: list[tuple[str, set[str]]] = []
371
- for cls in nodes(tree, ast.ClassDef):
372
- bases = {name for base in cls.bases if (name := _get_base_name(base)) is not None}
373
- if "TypedDict" in bases:
374
- declared.add(cls.name)
375
- elif bases:
376
- derived.append((cls.name, bases))
377
- for assign in nodes(tree, ast.Assign):
378
- value = assign.value
379
- if isinstance(value, ast.Call) and _get_base_name(value.func) == "TypedDict":
380
- declared.update(target.id for target in assign.targets if isinstance(target, ast.Name))
381
- grew = True
382
- while grew:
383
- grew = False
384
- for name, bases in derived:
385
- if name not in declared and bases & declared:
386
- declared.add(name)
387
- grew = True
388
- return frozenset(declared)
389
-
390
-
391
- def _annotation_heads(annotation: ast.expr) -> frozenset[str]:
392
- """Collect the type names an annotated value could be an instance of.
393
-
394
- `Optional[X]`, `Final[X]` and `X | None` all still describe an `X`, so the
395
- wrappers are unwrapped; `list[X]` is NOT, because a list of `X` is a list.
396
- A string forward reference is parsed and followed.
397
-
398
- Returns:
399
- The candidate type names, or an empty set for an unreadable annotation.
400
-
401
- """
402
- match annotation:
403
- case ast.Name(id=name):
404
- return frozenset({name})
405
- case ast.Attribute(attr=name):
406
- return frozenset({name})
407
- case ast.Constant(value=str() as text):
408
- inner = _parse_type_text(text)
409
- return _annotation_heads(inner) if inner is not None else frozenset()
410
- case ast.BinOp(op=ast.BitOr(), left=left, right=right):
411
- return _annotation_heads(left) | _annotation_heads(right)
412
- case ast.Subscript(value=value, slice=index) if _get_base_name(value) in _TYPE_WRAPPERS:
413
- elements = index.elts if isinstance(index, ast.Tuple) else [index]
414
- # Annotated rather than a bare `frozenset()`, whose element type is
415
- # unknown and makes the whole return type partially unknown.
416
- empty: frozenset[str] = frozenset()
417
- return empty.union(*(_annotation_heads(element) for element in elements))
418
- case ast.Subscript(value=value):
419
- head = _get_base_name(value)
420
- return frozenset({head}) if head is not None else frozenset()
421
- case _:
422
- return frozenset()
423
-
424
-
425
- def _parse_type_text(text: str) -> ast.expr | None:
426
- """Parse a string forward reference into the expression it names.
427
-
428
- Returns:
429
- The parsed expression, or None when the text is not one.
430
-
431
- """
432
- try:
433
- return ast.parse(text, mode="eval").body
434
- except SyntaxError, ValueError:
435
- return None
436
-
437
-
438
- # Modules whose exports are containers and escape hatches rather than schemas.
439
- # `from typing import Any` followed by `payload: Any` declares nothing.
440
- _STRUCTURELESS_IMPORT_MODULES = frozenset(
441
- {"builtins", "collections", "collections.abc", "typing", "typing_extensions"}
442
- )
443
-
444
-
445
- def _declared_type_names(tree: ast.Module) -> frozenset[str]:
446
- """Collect names imported into this module that could name a declared shape.
447
-
448
- A receiver annotated with a type this file IMPORTS has already been given a
449
- schema by its author -- most often a TypedDict, which subscripts by design:
450
- `AllMessageValues` in `litellm/…/prompt_templates/factory.py` is OpenAI's
451
- message TypedDict, and `current_message["role"]` is the DECLARATIVE form,
452
- not a substitute for one. `_typed_dict_class_names` sees only `class X
453
- (TypedDict)` written in the same file, so every cross-module TypedDict --
454
- which is nearly all of them -- was invisible.
455
-
456
- This cannot resolve the import to check what it really is, so it is
457
- deliberately generous: `typing`/`collections` exports are excluded because
458
- `Any` and `dict` declare nothing, and everything else is taken at its word.
459
-
460
- Returns:
461
- The imported names usable as an annotation head.
462
-
463
- """
464
- declared: set[str] = set()
465
- for node in nodes(tree, ast.Import, ast.ImportFrom):
466
- if isinstance(node, ast.ImportFrom) and node.module in _STRUCTURELESS_IMPORT_MODULES:
467
- continue
468
- declared.update(alias.asname or alias.name.split(".")[0] for alias in node.names)
469
- return frozenset(name for name in declared if name[:1].isupper())
470
-
471
-
472
- def _schema_bound_names(tree: ast.Module, typed_dicts: frozenset[str]) -> frozenset[str]:
473
- """Collect names whose declared type is one of this file's TypedDicts.
474
-
475
- Returns:
476
- The parameter and annotated-variable names bound to a TypedDict type.
477
-
478
- """
479
- bound: set[str] = set()
480
- for node in nodes(tree, ast.arg, ast.AnnAssign):
481
- match node:
482
- case ast.arg(arg=name, annotation=ast.expr() as annotation):
483
- pass
484
- case ast.AnnAssign(target=ast.Name(id=name), annotation=annotation):
485
- pass
486
- case _:
487
- continue
488
- if _annotation_heads(annotation) & typed_dicts:
489
- bound.add(name)
490
- return frozenset(bound)
491
-
492
-
493
- def _constant_tables(tree: ast.Module) -> frozenset[str]:
494
- """Collect SCREAMING_CASE names bound to a dict/list literal at a declaration scope.
495
-
496
- Module body and class body only: a constant table is declared, not computed,
497
- and a local `TABLE = {...}` inside a function body is not what the shape
498
- describes.
499
-
500
- Returns:
501
- The constant lookup-table names declared in this file.
502
-
503
- """
504
- tables: set[str] = set()
505
- bodies: list[list[ast.stmt]] = [tree.body]
506
- bodies += [cls.body for cls in nodes(tree, ast.ClassDef)]
507
- for body in bodies:
508
- for stmt in body:
509
- match stmt:
510
- case ast.Assign(targets=targets, value=ast.Dict() | ast.List()):
511
- tables.update(t.id for t in targets if isinstance(t, ast.Name) and t.id.isupper())
512
- case ast.AnnAssign(target=ast.Name(id=name), value=ast.Dict() | ast.List()) if name.isupper():
513
- tables.add(name)
514
- case _:
515
- pass
516
- return frozenset(tables)
517
-
518
-
519
- def _is_test_path(path: Path) -> bool:
520
- return path.name.startswith("test_") or "tests" in path.parts
521
-
522
-
523
- def _is_excluded_path(path: Path) -> bool:
524
- excluded = {".uv-cache", ".venv", "venv", "node_modules", "site-packages"}
525
- return bool(excluded.intersection(path.parts))