sarj-python-lint 0.67.14__tar.gz → 0.69.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.67.14 → sarj_python_lint-0.69.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_registry.py +6 -0
- sarj_python_lint-0.69.0/src/sarj_python_lint/rules/iac_source_coupled_test.py +107 -0
- sarj_python_lint-0.69.0/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +446 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/source_coupled_test.py +193 -53
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/.gitignore +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/LICENSE +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/README.md +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/unused_mock_setup.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.69.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
|
|
6
6
|
Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
|
{sarj_python_lint-0.67.14 → sarj_python_lint-0.69.0}/src/sarj_python_lint/rules/_registry.py
RENAMED
|
@@ -19,6 +19,7 @@ from sarj_python_lint.rules.duplicated_override_docstring import (
|
|
|
19
19
|
)
|
|
20
20
|
from sarj_python_lint.rules.fastapi_openapi_contract import FastapiOpenapiContract
|
|
21
21
|
from sarj_python_lint.rules.fixture_returns_bare_tuple import FixtureReturnsBareTuple
|
|
22
|
+
from sarj_python_lint.rules.iac_source_coupled_test import IacSourceCoupledTest
|
|
22
23
|
from sarj_python_lint.rules.invalid_pydantic_field_default import (
|
|
23
24
|
InvalidPydanticFieldDefault,
|
|
24
25
|
)
|
|
@@ -117,6 +118,9 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
|
117
118
|
RequireKeywordOnlySwapProneParams,
|
|
118
119
|
)
|
|
119
120
|
from sarj_python_lint.rules.require_port_for_service import RequirePortForService
|
|
121
|
+
from sarj_python_lint.rules.require_pydantic_for_external_json import (
|
|
122
|
+
RequirePydanticForExternalJson,
|
|
123
|
+
)
|
|
120
124
|
from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
|
|
121
125
|
from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
|
|
122
126
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
@@ -154,6 +158,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
154
158
|
NoCommentCruft.id: NoCommentCruft,
|
|
155
159
|
StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
|
|
156
160
|
SourceCoupledTest.id: SourceCoupledTest,
|
|
161
|
+
IacSourceCoupledTest.id: IacSourceCoupledTest,
|
|
157
162
|
NoQueryWithManyJoins.id: NoQueryWithManyJoins,
|
|
158
163
|
NoAggregationInStoreQuery.id: NoAggregationInStoreQuery,
|
|
159
164
|
NoSelectStar.id: NoSelectStar,
|
|
@@ -162,6 +167,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
162
167
|
NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
|
|
163
168
|
PreferMatchAssertNever.id: PreferMatchAssertNever,
|
|
164
169
|
RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
|
|
170
|
+
RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
|
|
165
171
|
NoFileLevelSuppression.id: NoFileLevelSuppression,
|
|
166
172
|
PreferModuleLevelConstant.id: PreferModuleLevelConstant,
|
|
167
173
|
PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""SARJ412 — tests must not assert on raw IaC source text.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_iac_source_coupled_test.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
Diagnostic,
|
|
14
|
+
ExampleFile,
|
|
15
|
+
ExampleOutcome,
|
|
16
|
+
Rule,
|
|
17
|
+
RuleCategory,
|
|
18
|
+
RuleDocumentation,
|
|
19
|
+
RuleExample,
|
|
20
|
+
Severity,
|
|
21
|
+
parse_or_none,
|
|
22
|
+
)
|
|
23
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
24
|
+
from sarj_python_lint.rules.source_coupled_test import FunctionAnalyzer, top_level_test_functions
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
IAC_SOURCE_SUFFIXES = (".hcl", ".tf", ".tf.json", ".tfvars", ".tftest.hcl", ".tftest.json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@final
|
|
35
|
+
class IacSourceCoupledTest(Rule):
|
|
36
|
+
id = "iac-source-coupled-test"
|
|
37
|
+
code = "SARJ412"
|
|
38
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
39
|
+
summary="Test asserts on raw IaC source text instead of a parsed plan, provider state, or runtime behavior.",
|
|
40
|
+
rationale=(
|
|
41
|
+
"Substring and regex checks can pass on comments, formatting, or unreachable Terraform configuration while clients fail silently."
|
|
42
|
+
),
|
|
43
|
+
remediation="Parse rendered plan JSON, query the provider, or exercise the deployed runtime contract.",
|
|
44
|
+
category=RuleCategory.TESTING,
|
|
45
|
+
limitations=(
|
|
46
|
+
"The rule follows local aliases, path collections, context-managed reads, and common normalization; interprocedural flows remain unreported.",
|
|
47
|
+
"Files produced beneath recognized temporary-directory fixtures are generated outputs and remain unreported.",
|
|
48
|
+
"The warning-stage rule remains suppressible for calibration; promotion may make the locked policy non-suppressible.",
|
|
49
|
+
),
|
|
50
|
+
examples=(
|
|
51
|
+
RuleExample(
|
|
52
|
+
example_id="rendered-plan-contract",
|
|
53
|
+
title="Assert on rendered Terraform plan behavior",
|
|
54
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
55
|
+
files=(
|
|
56
|
+
ExampleFile.python(
|
|
57
|
+
"tests/test_policy.py",
|
|
58
|
+
"def test_policy():\n plan = json.loads(Path('plan.json').read_text())\n assert verify(plan) == []\n",
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
focus_path=PurePosixPath("tests/test_policy.py"),
|
|
62
|
+
expected_count=0,
|
|
63
|
+
public=True,
|
|
64
|
+
),
|
|
65
|
+
RuleExample(
|
|
66
|
+
example_id="terraform-substring-contract",
|
|
67
|
+
title="Do not prove Terraform behavior with a substring",
|
|
68
|
+
outcome=ExampleOutcome.MATCH,
|
|
69
|
+
files=(
|
|
70
|
+
ExampleFile.python(
|
|
71
|
+
"tests/test_policy.py",
|
|
72
|
+
"def test_policy():\n source = Path('main.tf').read_text()\n assert 'prevent_destroy = true' in source\n",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
focus_path=PurePosixPath("tests/test_policy.py"),
|
|
76
|
+
expected_count=1,
|
|
77
|
+
public=True,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
description = documentation.summary
|
|
82
|
+
|
|
83
|
+
@override
|
|
84
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
85
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
86
|
+
return []
|
|
87
|
+
tree = parse_or_none(path, source)
|
|
88
|
+
if not isinstance(tree, ast.Module):
|
|
89
|
+
return []
|
|
90
|
+
assertions = [
|
|
91
|
+
assertion
|
|
92
|
+
for function, unittest_style in top_level_test_functions(tree)
|
|
93
|
+
for assertion in FunctionAnalyzer(IAC_SOURCE_SUFFIXES, unittest_style=unittest_style).analyze(function)
|
|
94
|
+
]
|
|
95
|
+
return [
|
|
96
|
+
Diagnostic(
|
|
97
|
+
path=path,
|
|
98
|
+
line=assertion.lineno,
|
|
99
|
+
col=assertion.col_offset + 1,
|
|
100
|
+
code=self.code,
|
|
101
|
+
severity=Severity.WARNING,
|
|
102
|
+
message=(
|
|
103
|
+
"raw IaC source text is the test oracle; inspect rendered plan JSON, provider state, or runtime behavior instead."
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
for assertion in assertions
|
|
107
|
+
]
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""SARJ411 — Validate external JSON before reading fixed fields.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_require_pydantic_for_external_json.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
from typing import TYPE_CHECKING, ClassVar, final, override
|
|
13
|
+
|
|
14
|
+
from sarj_python_lint.rule_base import (
|
|
15
|
+
AutofixPolicy,
|
|
16
|
+
Diagnostic,
|
|
17
|
+
ExampleFile,
|
|
18
|
+
ExampleOutcome,
|
|
19
|
+
Rule,
|
|
20
|
+
RuleCategory,
|
|
21
|
+
RuleDocumentation,
|
|
22
|
+
RuleExample,
|
|
23
|
+
Severity,
|
|
24
|
+
parse_or_none,
|
|
25
|
+
)
|
|
26
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
27
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_JSON_MODULES = frozenset({"json", "orjson", "rapidjson", "ujson"})
|
|
35
|
+
_HTTP_MODULES = frozenset({"httpx", "requests"})
|
|
36
|
+
_HTTP_METHODS = frozenset({"delete", "get", "head", "options", "patch", "post", "put", "request"})
|
|
37
|
+
_OBJECT_VALIDATORS = frozenset({"model_validate", "parse_obj"})
|
|
38
|
+
_ADAPTER_VALIDATORS = frozenset({"validate_python"})
|
|
39
|
+
_RECORD_METHODS = frozenset({"get", "items", "keys", "values"})
|
|
40
|
+
_LOCAL_READ_METHODS = frozenset({"read_bytes", "read_text"})
|
|
41
|
+
_DOCUMENTATION_DIR_NAMES = frozenset({"docs", "docs_src", "examples"})
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class _ModuleSummaries:
|
|
46
|
+
decoder_parameters: dict[str, int]
|
|
47
|
+
record_parameters: dict[str, frozenset[int]]
|
|
48
|
+
local_parameters: dict[str, frozenset[int]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@final
|
|
52
|
+
class RequirePydanticForExternalJson(Rule):
|
|
53
|
+
id = "require-pydantic-for-external-json"
|
|
54
|
+
code = "SARJ411"
|
|
55
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
56
|
+
summary="Externally sourced JSON is consumed without runtime schema validation.",
|
|
57
|
+
rationale=(
|
|
58
|
+
"Annotations, casts, key-by-key checks, and dictionary access do not validate a wire protocol; "
|
|
59
|
+
"Pydantic makes required fields, types, and protocol versions explicit at the boundary."
|
|
60
|
+
),
|
|
61
|
+
remediation=(
|
|
62
|
+
"Use `Model.model_validate_json(payload)` or `TypeAdapter(Model).validate_json(payload)`, or "
|
|
63
|
+
"validate an already-decoded value with `model_validate` or `validate_python` before use."
|
|
64
|
+
),
|
|
65
|
+
category=RuleCategory.CORRECTNESS,
|
|
66
|
+
autofix=AutofixPolicy.NONE,
|
|
67
|
+
limitations=(
|
|
68
|
+
"The rule follows common JSON decoders and simple module-local helpers through single-assignment names.",
|
|
69
|
+
"It diagnoses literal-key record access; dynamic JSON documents without fixed-field access remain out of scope.",
|
|
70
|
+
"Repository-local JSON, json.load file handles, tests, generated files, and documentation examples are excluded.",
|
|
71
|
+
),
|
|
72
|
+
examples=(
|
|
73
|
+
RuleExample(
|
|
74
|
+
example_id="manual-external-json-access",
|
|
75
|
+
title="External JSON read as a dictionary",
|
|
76
|
+
outcome=ExampleOutcome.MATCH,
|
|
77
|
+
files=(
|
|
78
|
+
ExampleFile.python(
|
|
79
|
+
"protocol.py",
|
|
80
|
+
"import json\n\ndef parse(payload: str) -> object:\n report = json.loads(payload)\n return report.get('version')\n",
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
focus_path=PurePosixPath("protocol.py"),
|
|
84
|
+
expected_count=1,
|
|
85
|
+
public=True,
|
|
86
|
+
),
|
|
87
|
+
RuleExample(
|
|
88
|
+
example_id="pydantic-external-json-validation",
|
|
89
|
+
title="External JSON validated by a boundary model",
|
|
90
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
91
|
+
files=(
|
|
92
|
+
ExampleFile.python(
|
|
93
|
+
"protocol.py",
|
|
94
|
+
"def parse(payload: str) -> Report:\n return Report.model_validate_json(payload)\n",
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
focus_path=PurePosixPath("protocol.py"),
|
|
98
|
+
expected_count=0,
|
|
99
|
+
public=True,
|
|
100
|
+
),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
description = documentation.summary
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
107
|
+
if _excluded(path, source) or ("loads" not in source and ".json(" not in source):
|
|
108
|
+
return []
|
|
109
|
+
tree = parse_or_none(path, source)
|
|
110
|
+
if tree is None:
|
|
111
|
+
return []
|
|
112
|
+
imports = ImportIndex.from_tree(tree)
|
|
113
|
+
summaries = _module_summaries(tree, imports)
|
|
114
|
+
findings: list[tuple[ast.expr, ast.Call]] = []
|
|
115
|
+
for function in _functions(tree):
|
|
116
|
+
findings.extend(_function_findings(function, imports, summaries))
|
|
117
|
+
|
|
118
|
+
first_by_origin: dict[int, tuple[ast.expr, ast.Call]] = {}
|
|
119
|
+
for sink, origin in sorted(findings, key=lambda item: (item[0].lineno, item[0].col_offset)):
|
|
120
|
+
first_by_origin.setdefault(id(origin), (sink, origin))
|
|
121
|
+
return [
|
|
122
|
+
Diagnostic(
|
|
123
|
+
path=path,
|
|
124
|
+
line=sink.lineno,
|
|
125
|
+
col=sink.col_offset + 1,
|
|
126
|
+
code=self.code,
|
|
127
|
+
message=(
|
|
128
|
+
"External JSON field is read without Pydantic validation — validate the payload with "
|
|
129
|
+
"`Model.model_validate_json(...)` or `TypeAdapter(Model).validate_json(...)` first."
|
|
130
|
+
),
|
|
131
|
+
severity=Severity.WARNING,
|
|
132
|
+
)
|
|
133
|
+
for sink, _origin in first_by_origin.values()
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _functions(tree: ast.Module) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ...]:
|
|
138
|
+
return tuple(node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _module_summaries(tree: ast.Module, imports: ImportIndex) -> _ModuleSummaries:
|
|
142
|
+
functions = tuple(
|
|
143
|
+
statement for statement in tree.body if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
144
|
+
)
|
|
145
|
+
duplicate_names = {name for name, count in Counter(function.name for function in functions).items() if count > 1}
|
|
146
|
+
decoders: dict[str, int] = {}
|
|
147
|
+
records: dict[str, frozenset[int]] = {}
|
|
148
|
+
for function in functions:
|
|
149
|
+
if function.name in duplicate_names:
|
|
150
|
+
continue
|
|
151
|
+
parameters = _parameter_positions(function)
|
|
152
|
+
returned = tuple(node.value for node in _own_scope(function) if isinstance(node, ast.Return) and node.value)
|
|
153
|
+
decoder_positions = {
|
|
154
|
+
position
|
|
155
|
+
for value in returned
|
|
156
|
+
if (position := _decoded_parameter_position(value, parameters, imports)) is not None
|
|
157
|
+
}
|
|
158
|
+
if (
|
|
159
|
+
returned
|
|
160
|
+
and len(decoder_positions) == 1
|
|
161
|
+
and all(_decoded_parameter_position(value, parameters, imports) in decoder_positions for value in returned)
|
|
162
|
+
):
|
|
163
|
+
decoders[function.name] = decoder_positions.pop()
|
|
164
|
+
|
|
165
|
+
consumed: set[int] = set()
|
|
166
|
+
for node in _own_scope(function):
|
|
167
|
+
receiver = _summary_record_receiver(node)
|
|
168
|
+
if isinstance(receiver, ast.Name) and receiver.id in parameters:
|
|
169
|
+
consumed.add(parameters[receiver.id])
|
|
170
|
+
if consumed:
|
|
171
|
+
records[function.name] = frozenset(consumed)
|
|
172
|
+
return _ModuleSummaries(decoders, records, _locally_sourced_parameters(functions))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _locally_sourced_parameters(
|
|
176
|
+
functions: tuple[ast.FunctionDef | ast.AsyncFunctionDef, ...],
|
|
177
|
+
) -> dict[str, frozenset[int]]:
|
|
178
|
+
calls: dict[str, list[ast.Call]] = {}
|
|
179
|
+
for owner in functions:
|
|
180
|
+
for node in _own_scope(owner):
|
|
181
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
182
|
+
calls.setdefault(node.func.id, []).append(node)
|
|
183
|
+
local: dict[str, frozenset[int]] = {}
|
|
184
|
+
for function in functions:
|
|
185
|
+
callsites = calls.get(function.name, [])
|
|
186
|
+
if not callsites:
|
|
187
|
+
continue
|
|
188
|
+
positions = {
|
|
189
|
+
position
|
|
190
|
+
for position in range(len(_parameter_positions(function)))
|
|
191
|
+
if all(position < len(call.args) and _is_local_json_text(call.args[position]) for call in callsites)
|
|
192
|
+
}
|
|
193
|
+
if positions:
|
|
194
|
+
local[function.name] = frozenset(positions)
|
|
195
|
+
return local
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _decoded_parameter_position(
|
|
199
|
+
value: ast.expr,
|
|
200
|
+
parameters: dict[str, int],
|
|
201
|
+
imports: ImportIndex,
|
|
202
|
+
) -> int | None:
|
|
203
|
+
value = _unwrap_await(value)
|
|
204
|
+
if not isinstance(value, ast.Call) or not _is_json_loads(value, imports) or not value.args:
|
|
205
|
+
return None
|
|
206
|
+
argument = value.args[0]
|
|
207
|
+
return parameters.get(argument.id) if isinstance(argument, ast.Name) else None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parameter_positions(function: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, int]:
|
|
211
|
+
arguments = (*function.args.posonlyargs, *function.args.args)
|
|
212
|
+
return {argument.arg: position for position, argument in enumerate(arguments)}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _function_findings(
|
|
216
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
217
|
+
imports: ImportIndex,
|
|
218
|
+
summaries: _ModuleSummaries,
|
|
219
|
+
) -> list[tuple[ast.expr, ast.Call]]:
|
|
220
|
+
scope = _own_scope(function)
|
|
221
|
+
local_positions = summaries.local_parameters.get(function.name, frozenset())
|
|
222
|
+
parameters = frozenset(
|
|
223
|
+
name for name, position in _parameter_positions(function).items() if position not in local_positions
|
|
224
|
+
)
|
|
225
|
+
outbound_requests = _outbound_request_parameters(function, imports)
|
|
226
|
+
response_names = _http_response_names(scope, imports)
|
|
227
|
+
bindings = _unique_bindings(scope)
|
|
228
|
+
resolver = _OriginResolver(imports, summaries, parameters, outbound_requests, response_names, bindings)
|
|
229
|
+
findings: list[tuple[ast.expr, ast.Call]] = []
|
|
230
|
+
for node in scope:
|
|
231
|
+
access = _record_access(node)
|
|
232
|
+
if access is not None:
|
|
233
|
+
sink, receiver = access
|
|
234
|
+
findings.extend((sink, origin) for origin in resolver.origins(receiver))
|
|
235
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
236
|
+
for position in summaries.record_parameters.get(node.func.id, frozenset()):
|
|
237
|
+
if position < len(node.args):
|
|
238
|
+
findings.extend((node, origin) for origin in resolver.origins(node.args[position]))
|
|
239
|
+
return findings
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(slots=True)
|
|
243
|
+
class _OriginResolver:
|
|
244
|
+
imports: ImportIndex
|
|
245
|
+
summaries: _ModuleSummaries
|
|
246
|
+
parameters: frozenset[str]
|
|
247
|
+
outbound_request_parameters: frozenset[str]
|
|
248
|
+
response_names: frozenset[str]
|
|
249
|
+
bindings: dict[str, ast.expr]
|
|
250
|
+
|
|
251
|
+
def origins(self, expression: ast.expr, resolving: frozenset[str] = frozenset()) -> frozenset[ast.Call]:
|
|
252
|
+
expression = _unwrap_await(expression)
|
|
253
|
+
if isinstance(expression, ast.Name):
|
|
254
|
+
if expression.id in resolving or (value := self.bindings.get(expression.id)) is None:
|
|
255
|
+
return frozenset()
|
|
256
|
+
return self.origins(value, resolving | {expression.id})
|
|
257
|
+
if isinstance(expression, ast.Call):
|
|
258
|
+
if _is_validation_call(expression, self.imports) or _is_model_unpack_validation(expression):
|
|
259
|
+
return frozenset()
|
|
260
|
+
if self._is_source(expression):
|
|
261
|
+
return frozenset({expression})
|
|
262
|
+
found_origins: set[ast.Call] = set()
|
|
263
|
+
if (
|
|
264
|
+
isinstance(expression.func, ast.Name)
|
|
265
|
+
and (position := self.summaries.decoder_parameters.get(expression.func.id)) is not None
|
|
266
|
+
and position < len(expression.args)
|
|
267
|
+
and self._is_external_input(expression.args[position])
|
|
268
|
+
):
|
|
269
|
+
found_origins.add(expression)
|
|
270
|
+
for argument in expression.args:
|
|
271
|
+
found_origins.update(self.origins(argument, resolving))
|
|
272
|
+
for keyword in expression.keywords:
|
|
273
|
+
found_origins.update(self.origins(keyword.value, resolving))
|
|
274
|
+
return frozenset(found_origins)
|
|
275
|
+
found_origins = set()
|
|
276
|
+
for child in ast.iter_child_nodes(expression):
|
|
277
|
+
if isinstance(child, ast.expr):
|
|
278
|
+
found_origins.update(self.origins(child, resolving))
|
|
279
|
+
return frozenset(found_origins)
|
|
280
|
+
|
|
281
|
+
def _is_source(self, call: ast.Call) -> bool:
|
|
282
|
+
if _is_json_loads(call, self.imports) and call.args:
|
|
283
|
+
return self._is_external_input(call.args[0])
|
|
284
|
+
return (
|
|
285
|
+
isinstance(call.func, ast.Attribute)
|
|
286
|
+
and call.func.attr == "json"
|
|
287
|
+
and not call.args
|
|
288
|
+
and isinstance(call.func.value, ast.Name)
|
|
289
|
+
and call.func.value.id in self.response_names
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def _is_external_input(self, expression: ast.expr) -> bool:
|
|
293
|
+
expression = _unwrap_await(expression)
|
|
294
|
+
match expression:
|
|
295
|
+
case ast.Name(id=name):
|
|
296
|
+
return name in self.parameters
|
|
297
|
+
case ast.Attribute(value=ast.Name(id=owner), attr=attribute) if owner in self.outbound_request_parameters:
|
|
298
|
+
return False
|
|
299
|
+
case ast.Attribute(attr=attribute):
|
|
300
|
+
return attribute in {"content", "stderr", "stdout", "text"}
|
|
301
|
+
case ast.Subscript(value=value):
|
|
302
|
+
return _dotted_name(value) in {"environ", "os.environ"}
|
|
303
|
+
case _:
|
|
304
|
+
return not _is_local_json_text(expression)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _record_access(node: ast.AST) -> tuple[ast.expr, ast.expr] | None:
|
|
308
|
+
if isinstance(node, ast.Subscript) and _literal_string(node.slice) is not None:
|
|
309
|
+
return node, node.value
|
|
310
|
+
if isinstance(node, ast.Call) and (receiver := _fixed_record_call_receiver(node)) is not None:
|
|
311
|
+
return node, receiver
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _fixed_record_call_receiver(node: ast.Call) -> ast.expr | None:
|
|
316
|
+
if not isinstance(node.func, ast.Attribute) or node.func.attr not in _RECORD_METHODS:
|
|
317
|
+
return None
|
|
318
|
+
if node.func.attr == "get" and not (node.args and _literal_string(node.args[0]) is not None):
|
|
319
|
+
return None
|
|
320
|
+
return node.func.value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _summary_record_receiver(node: ast.AST) -> ast.expr | None:
|
|
324
|
+
"""Return a helper's record receiver even when its key is a parameter."""
|
|
325
|
+
if isinstance(node, ast.Subscript):
|
|
326
|
+
return node.value
|
|
327
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr in _RECORD_METHODS:
|
|
328
|
+
return node.func.value
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _unique_bindings(scope: tuple[ast.AST, ...]) -> dict[str, ast.expr]:
|
|
333
|
+
candidates: dict[str, list[ast.expr]] = {}
|
|
334
|
+
for node in scope:
|
|
335
|
+
if isinstance(node, ast.Assign):
|
|
336
|
+
for target in node.targets:
|
|
337
|
+
if isinstance(target, ast.Name):
|
|
338
|
+
candidates.setdefault(target.id, []).append(node.value)
|
|
339
|
+
elif (binding := _single_named_binding(node)) is not None:
|
|
340
|
+
name, value = binding
|
|
341
|
+
candidates.setdefault(name, []).append(value)
|
|
342
|
+
return {name: values[0] for name, values in candidates.items() if len(values) == 1}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _single_named_binding(node: ast.AST) -> tuple[str, ast.expr] | None:
|
|
346
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.value is not None:
|
|
347
|
+
return node.target.id, node.value
|
|
348
|
+
if isinstance(node, ast.NamedExpr):
|
|
349
|
+
return node.target.id, node.value
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _http_response_names(scope: tuple[ast.AST, ...], imports: ImportIndex) -> frozenset[str]:
|
|
354
|
+
names: set[str] = set()
|
|
355
|
+
for node in scope:
|
|
356
|
+
if not isinstance(node, (ast.Assign, ast.AnnAssign)) or node.value is None:
|
|
357
|
+
continue
|
|
358
|
+
call = _unwrap_await(node.value)
|
|
359
|
+
if not isinstance(call, ast.Call) or not any(
|
|
360
|
+
imports.resolves(call.func, sources=_HTTP_MODULES, symbol=method) for method in _HTTP_METHODS
|
|
361
|
+
):
|
|
362
|
+
continue
|
|
363
|
+
targets = node.targets if isinstance(node, ast.Assign) else (node.target,)
|
|
364
|
+
names.update(target.id for target in targets if isinstance(target, ast.Name))
|
|
365
|
+
return frozenset(names)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _outbound_request_parameters(
|
|
369
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex
|
|
370
|
+
) -> frozenset[str]:
|
|
371
|
+
arguments = (*function.args.posonlyargs, *function.args.args, *function.args.kwonlyargs)
|
|
372
|
+
return frozenset(
|
|
373
|
+
argument.arg
|
|
374
|
+
for argument in arguments
|
|
375
|
+
if argument.annotation is not None
|
|
376
|
+
and imports.resolves(argument.annotation, sources=_HTTP_MODULES, symbol="Request")
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _is_json_loads(call: ast.Call, imports: ImportIndex) -> bool:
|
|
381
|
+
return imports.resolves(call.func, sources=_JSON_MODULES, symbol="loads")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_validation_call(call: ast.Call, imports: ImportIndex) -> bool:
|
|
385
|
+
if isinstance(call.func, ast.Attribute) and call.func.attr in (_OBJECT_VALIDATORS | _ADAPTER_VALIDATORS):
|
|
386
|
+
return True
|
|
387
|
+
return imports.resolves(call.func, sources=frozenset({"pydantic"}), symbol="parse_obj_as")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _is_model_unpack_validation(call: ast.Call) -> bool:
|
|
391
|
+
return (
|
|
392
|
+
isinstance(call.func, ast.Name)
|
|
393
|
+
and call.func.id[:1].isupper()
|
|
394
|
+
and any(keyword.arg is None for keyword in call.keywords)
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _is_local_json_text(expression: ast.expr) -> bool:
|
|
399
|
+
expression = _unwrap_await(expression)
|
|
400
|
+
if isinstance(expression, ast.Constant) and isinstance(expression.value, (str, bytes)):
|
|
401
|
+
return True
|
|
402
|
+
return (
|
|
403
|
+
isinstance(expression, ast.Call)
|
|
404
|
+
and isinstance(expression.func, ast.Attribute)
|
|
405
|
+
and expression.func.attr in _LOCAL_READ_METHODS
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _dotted_name(node: ast.expr) -> str | None:
|
|
410
|
+
parts: list[str] = []
|
|
411
|
+
current = node
|
|
412
|
+
while isinstance(current, ast.Attribute):
|
|
413
|
+
parts.append(current.attr)
|
|
414
|
+
current = current.value
|
|
415
|
+
if not isinstance(current, ast.Name):
|
|
416
|
+
return None
|
|
417
|
+
return ".".join((current.id, *reversed(parts)))
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _literal_string(node: ast.expr) -> str | None:
|
|
421
|
+
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _unwrap_await(node: ast.expr) -> ast.expr:
|
|
425
|
+
return node.value if isinstance(node, ast.Await) else node
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _excluded(path: Path, source: str) -> bool:
|
|
429
|
+
return (
|
|
430
|
+
is_test_path(path)
|
|
431
|
+
or is_test_support_path(path)
|
|
432
|
+
or is_generated(path, source)
|
|
433
|
+
or any(part.lower() in _DOCUMENTATION_DIR_NAMES for part in path.parts)
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _own_scope(function: ast.FunctionDef | ast.AsyncFunctionDef) -> tuple[ast.AST, ...]:
|
|
438
|
+
out: list[ast.AST] = []
|
|
439
|
+
stack: list[ast.AST] = list(reversed(function.body))
|
|
440
|
+
while stack:
|
|
441
|
+
node = stack.pop()
|
|
442
|
+
out.append(node)
|
|
443
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)):
|
|
444
|
+
continue
|
|
445
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
446
|
+
return tuple(out)
|