sarj-python-lint 0.67.13__tar.gz → 0.68.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.13 → sarj_python_lint-0.68.0}/PKG-INFO +1 -1
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_registry.py +12 -0
- sarj_python_lint-0.68.0/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +169 -0
- sarj_python_lint-0.68.0/src/sarj_python_lint/rules/production_derived_test_cases.py +191 -0
- sarj_python_lint-0.68.0/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +446 -0
- sarj_python_lint-0.68.0/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +176 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/.gitignore +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/LICENSE +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/README.md +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.67.13 → sarj_python_lint-0.68.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.68.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.13 → sarj_python_lint-0.68.0}/src/sarj_python_lint/rules/_registry.py
RENAMED
|
@@ -24,6 +24,9 @@ from sarj_python_lint.rules.invalid_pydantic_field_default import (
|
|
|
24
24
|
)
|
|
25
25
|
from sarj_python_lint.rules.kwarg_heavy_construction_in_test import KwargHeavyConstructionInTest
|
|
26
26
|
from sarj_python_lint.rules.mock_without_spec import MockWithoutSpec
|
|
27
|
+
from sarj_python_lint.rules.negative_only_http_status_assertion import (
|
|
28
|
+
NegativeOnlyHttpStatusAssertion,
|
|
29
|
+
)
|
|
27
30
|
from sarj_python_lint.rules.no_aggregation_in_store_query import (
|
|
28
31
|
NoAggregationInStoreQuery,
|
|
29
32
|
)
|
|
@@ -105,6 +108,7 @@ from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
|
|
|
105
108
|
)
|
|
106
109
|
from sarj_python_lint.rules.prefer_walrus_regex_match import PreferWalrusRegexMatch
|
|
107
110
|
from sarj_python_lint.rules.prefer_walrus_stream_loop import PreferWalrusStreamLoop
|
|
111
|
+
from sarj_python_lint.rules.production_derived_test_cases import ProductionDerivedTestCases
|
|
108
112
|
from sarj_python_lint.rules.pydantic_at_boundaries import PydanticAtBoundaries
|
|
109
113
|
from sarj_python_lint.rules.redundant_class_docstring import RedundantClassDocstring
|
|
110
114
|
from sarj_python_lint.rules.redundant_docstring import RedundantDocstring
|
|
@@ -113,6 +117,9 @@ from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
|
|
|
113
117
|
RequireKeywordOnlySwapProneParams,
|
|
114
118
|
)
|
|
115
119
|
from sarj_python_lint.rules.require_port_for_service import RequirePortForService
|
|
120
|
+
from sarj_python_lint.rules.require_pydantic_for_external_json import (
|
|
121
|
+
RequirePydanticForExternalJson,
|
|
122
|
+
)
|
|
116
123
|
from sarj_python_lint.rules.restated_test_docstring import RestatedTestDocstring
|
|
117
124
|
from sarj_python_lint.rules.source_coupled_test import SourceCoupledTest
|
|
118
125
|
from sarj_python_lint.rules.stepdown import Stepdown
|
|
@@ -121,6 +128,7 @@ from sarj_python_lint.rules.store_insert_requires_on_conflict import (
|
|
|
121
128
|
)
|
|
122
129
|
from sarj_python_lint.rules.trailing_value_narration import TrailingValueNarration
|
|
123
130
|
from sarj_python_lint.rules.trivially_true_assertion import TriviallyTrueAssertion
|
|
131
|
+
from sarj_python_lint.rules.uncontrolled_randomness_in_test import UncontrolledRandomnessInTest
|
|
124
132
|
from sarj_python_lint.rules.unused_mock_setup import UnusedMockSetup
|
|
125
133
|
|
|
126
134
|
|
|
@@ -157,6 +165,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
157
165
|
NoRepeatedStringLiteral.id: NoRepeatedStringLiteral,
|
|
158
166
|
PreferMatchAssertNever.id: PreferMatchAssertNever,
|
|
159
167
|
RequireKeywordOnlySwapProneParams.id: RequireKeywordOnlySwapProneParams,
|
|
168
|
+
RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
|
|
160
169
|
NoFileLevelSuppression.id: NoFileLevelSuppression,
|
|
161
170
|
PreferModuleLevelConstant.id: PreferModuleLevelConstant,
|
|
162
171
|
PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
|
|
@@ -204,6 +213,9 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
204
213
|
NoTypedDocSections.id: NoTypedDocSections,
|
|
205
214
|
PreferNominalIdTypes.id: PreferNominalIdTypes,
|
|
206
215
|
NoUniqueViolationMessageMatch.id: NoUniqueViolationMessageMatch,
|
|
216
|
+
NegativeOnlyHttpStatusAssertion.id: NegativeOnlyHttpStatusAssertion,
|
|
217
|
+
ProductionDerivedTestCases.id: ProductionDerivedTestCases,
|
|
218
|
+
UncontrolledRandomnessInTest.id: UncontrolledRandomnessInTest,
|
|
207
219
|
}
|
|
208
220
|
)
|
|
209
221
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""SARJ408 — A negative-only HTTP status assertion permits wrong non-500 responses.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_negative_only_http_status_assertion.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, final, override
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
AutofixPolicy,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
ExampleFile,
|
|
16
|
+
ExampleOutcome,
|
|
17
|
+
Rule,
|
|
18
|
+
RuleCategory,
|
|
19
|
+
RuleDocumentation,
|
|
20
|
+
RuleExample,
|
|
21
|
+
Severity,
|
|
22
|
+
parse_or_none,
|
|
23
|
+
)
|
|
24
|
+
from sarj_python_lint.rules._paths import is_test_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_SERVER_ERROR_MIN = 500
|
|
33
|
+
_SERVER_ERROR_MAX = 599
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _int(node: ast.expr) -> int | None:
|
|
37
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool):
|
|
38
|
+
return node.value
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _negative_only(compare: ast.Compare) -> bool:
|
|
43
|
+
if len(compare.ops) != 1 or len(compare.comparators) != 1:
|
|
44
|
+
return False
|
|
45
|
+
left = compare.left
|
|
46
|
+
right = compare.comparators[0]
|
|
47
|
+
op = compare.ops[0]
|
|
48
|
+
match op:
|
|
49
|
+
case ast.NotEq():
|
|
50
|
+
return (_status_code(left) and _int(right) == _SERVER_ERROR_MIN) or (
|
|
51
|
+
_int(left) == _SERVER_ERROR_MIN and _status_code(right)
|
|
52
|
+
)
|
|
53
|
+
case ast.Lt() if _status_code(left):
|
|
54
|
+
return _int(right) == _SERVER_ERROR_MIN
|
|
55
|
+
case ast.LtE() if _status_code(left):
|
|
56
|
+
return _int(right) == _SERVER_ERROR_MIN - 1
|
|
57
|
+
case ast.NotIn() if _status_code(left):
|
|
58
|
+
return _all_server_errors(right)
|
|
59
|
+
case _:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _status_code(node: ast.expr) -> bool:
|
|
64
|
+
return isinstance(node, ast.Attribute) and node.attr == "status_code"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _all_server_errors(node: ast.expr) -> bool:
|
|
68
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "range":
|
|
69
|
+
values = [_int(arg) for arg in node.args]
|
|
70
|
+
return values == [_SERVER_ERROR_MIN, _SERVER_ERROR_MAX + 1]
|
|
71
|
+
if not isinstance(node, (ast.Set, ast.List, ast.Tuple)) or not node.elts:
|
|
72
|
+
return False
|
|
73
|
+
values = [_int(elt) for elt in node.elts]
|
|
74
|
+
return all(value is not None and _SERVER_ERROR_MIN <= value <= _SERVER_ERROR_MAX for value in values)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _test_nodes(test: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
|
|
78
|
+
"""Walk one collected test without attributing nested helpers to it."""
|
|
79
|
+
stack: list[ast.AST] = [*reversed(test.body)]
|
|
80
|
+
while stack:
|
|
81
|
+
node = stack.pop()
|
|
82
|
+
yield node
|
|
83
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
84
|
+
continue
|
|
85
|
+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@final
|
|
89
|
+
class NegativeOnlyHttpStatusAssertion(Rule):
|
|
90
|
+
id = "negative-only-http-status-assertion"
|
|
91
|
+
code = "SARJ408"
|
|
92
|
+
documentation = RuleDocumentation(
|
|
93
|
+
summary="HTTP test assertion only excludes a server error instead of identifying the intended response.",
|
|
94
|
+
rationale=(
|
|
95
|
+
"Authentication, routing, validation, and domain failures can all replace the intended response while "
|
|
96
|
+
"a negative-only status assertion remains green."
|
|
97
|
+
),
|
|
98
|
+
remediation="Assert the intended exact status and the relevant domain payload or side effect.",
|
|
99
|
+
category=RuleCategory.TESTING,
|
|
100
|
+
autofix=AutofixPolicy.NONE,
|
|
101
|
+
limitations=(
|
|
102
|
+
"Only assertions on an attribute named `status_code` in collected Python tests are checked.",
|
|
103
|
+
"Exact status contracts, finite success sets, arbitrary `.status` attributes, and locally suppressed chaos tests are excluded.",
|
|
104
|
+
"The intended status cannot be inferred safely, so the rule has no autofix.",
|
|
105
|
+
),
|
|
106
|
+
examples=(
|
|
107
|
+
RuleExample(
|
|
108
|
+
example_id="exact-validation-status",
|
|
109
|
+
title="Assert the intended validation response",
|
|
110
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
111
|
+
files=(
|
|
112
|
+
ExampleFile.python(
|
|
113
|
+
"tests/test_route.py",
|
|
114
|
+
"def test_invalid_payload(client):\n response = client.post('/items', json={})\n assert response.status_code == 422\n",
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
focus_path=PurePosixPath("tests/test_route.py"),
|
|
118
|
+
expected_count=0,
|
|
119
|
+
public=True,
|
|
120
|
+
),
|
|
121
|
+
RuleExample(
|
|
122
|
+
example_id="negative-only-server-status",
|
|
123
|
+
title="Do not accept every non-server-error response",
|
|
124
|
+
outcome=ExampleOutcome.MATCH,
|
|
125
|
+
files=(
|
|
126
|
+
ExampleFile.python(
|
|
127
|
+
"tests/test_route.py",
|
|
128
|
+
"def test_invalid_payload(client):\n response = client.post('/items', json={})\n assert response.status_code != 500\n",
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
focus_path=PurePosixPath("tests/test_route.py"),
|
|
132
|
+
expected_count=1,
|
|
133
|
+
public=True,
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
description = documentation.summary
|
|
138
|
+
|
|
139
|
+
@override
|
|
140
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
141
|
+
if not is_test_path(path):
|
|
142
|
+
return []
|
|
143
|
+
tree = parse_or_none(path, source)
|
|
144
|
+
if tree is None:
|
|
145
|
+
return []
|
|
146
|
+
tests = {
|
|
147
|
+
child
|
|
148
|
+
for child in ast.walk(tree)
|
|
149
|
+
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_")
|
|
150
|
+
}
|
|
151
|
+
findings: list[Diagnostic] = []
|
|
152
|
+
for test in tests:
|
|
153
|
+
findings.extend(
|
|
154
|
+
Diagnostic(
|
|
155
|
+
path=path,
|
|
156
|
+
line=node.lineno,
|
|
157
|
+
col=node.col_offset + 1,
|
|
158
|
+
code=self.code,
|
|
159
|
+
severity=Severity.WARNING,
|
|
160
|
+
message=(
|
|
161
|
+
"this assertion proves only that the response avoided one server-error shape; assert the "
|
|
162
|
+
"intended exact status and, when relevant, its domain payload or side effect."
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
for node in _test_nodes(test)
|
|
166
|
+
if isinstance(node, ast.Assert) and isinstance(node.test, ast.Compare) and _negative_only(node.test)
|
|
167
|
+
)
|
|
168
|
+
findings.sort(key=lambda finding: (finding.line, finding.col))
|
|
169
|
+
return findings
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""SARJ409 — Imported production collections are not independent parametrized test oracles.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_production_derived_test_cases.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import TYPE_CHECKING, final, override
|
|
11
|
+
|
|
12
|
+
from sarj_python_lint.rule_base import (
|
|
13
|
+
AutofixPolicy,
|
|
14
|
+
Diagnostic,
|
|
15
|
+
ExampleFile,
|
|
16
|
+
ExampleOutcome,
|
|
17
|
+
Rule,
|
|
18
|
+
RuleCategory,
|
|
19
|
+
RuleDocumentation,
|
|
20
|
+
RuleExample,
|
|
21
|
+
Severity,
|
|
22
|
+
parse_or_none,
|
|
23
|
+
)
|
|
24
|
+
from sarj_python_lint.rules._paths import is_test_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_COLLECTION_WRAPPERS = frozenset({"sorted", "set", "tuple"})
|
|
32
|
+
_PARAMETRIZE_CASES_INDEX = 1
|
|
33
|
+
_PARAMETRIZE_MIN_ARGS = 2
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _imported_bindings(tree: ast.Module) -> set[str]:
|
|
37
|
+
bindings: set[str] = set()
|
|
38
|
+
for node in tree.body:
|
|
39
|
+
if (
|
|
40
|
+
isinstance(node, ast.ImportFrom)
|
|
41
|
+
and node.module is not None
|
|
42
|
+
and not node.module.startswith(("test", "tests"))
|
|
43
|
+
):
|
|
44
|
+
bindings.update(alias.asname or alias.name for alias in node.names if alias.name != "*")
|
|
45
|
+
return bindings
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _direct_imported_collection(node: ast.expr, imported: set[str]) -> ast.Name | None:
|
|
49
|
+
if isinstance(node, ast.Name) and node.id in imported and node.id.isupper() and not _is_registry(node.id):
|
|
50
|
+
return node
|
|
51
|
+
if isinstance(node, ast.Call) and _is_imported_collection_wrapper(node, imported):
|
|
52
|
+
# `list(Enum)` / `tuple(Enum)` is an intentional exhaustiveness sweep;
|
|
53
|
+
# only set/sorted/tuple over an ALL_CAPS collection is the risky shape.
|
|
54
|
+
candidate = node.args[0]
|
|
55
|
+
return (
|
|
56
|
+
candidate
|
|
57
|
+
if isinstance(candidate, ast.Name) and candidate.id.isupper() and not _is_registry(candidate.id)
|
|
58
|
+
else None
|
|
59
|
+
)
|
|
60
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Sub, ast.BitOr, ast.BitAnd)):
|
|
61
|
+
for side in (node.left, node.right):
|
|
62
|
+
found = _direct_imported_collection(side, imported)
|
|
63
|
+
if found is not None and found.id.isupper():
|
|
64
|
+
return found
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_registry(name: str) -> bool:
|
|
69
|
+
return name == "REGISTRY" or name.endswith("_REGISTRY")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_imported_collection_wrapper(node: ast.Call, imported: set[str]) -> bool:
|
|
73
|
+
return (
|
|
74
|
+
isinstance(node.func, ast.Name)
|
|
75
|
+
and node.func.id in _COLLECTION_WRAPPERS
|
|
76
|
+
and len(node.args) == 1
|
|
77
|
+
and isinstance(node.args[0], ast.Name)
|
|
78
|
+
and node.args[0].id in imported
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parametrize_cases(decorator: ast.expr) -> ast.expr | None:
|
|
83
|
+
if not isinstance(decorator, ast.Call) or len(decorator.args) < _PARAMETRIZE_MIN_ARGS:
|
|
84
|
+
return None
|
|
85
|
+
func = decorator.func
|
|
86
|
+
if not isinstance(func, ast.Attribute) or func.attr != "parametrize":
|
|
87
|
+
return None
|
|
88
|
+
return decorator.args[_PARAMETRIZE_CASES_INDEX]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _independently_asserted_collections(tree: ast.Module, imported: set[str]) -> set[str]:
|
|
92
|
+
asserted: set[str] = set()
|
|
93
|
+
for node in ast.walk(tree):
|
|
94
|
+
if not isinstance(node, ast.Assert) or not isinstance(node.test, ast.Compare):
|
|
95
|
+
continue
|
|
96
|
+
compare = node.test
|
|
97
|
+
if len(compare.ops) != 1 or not isinstance(compare.ops[0], ast.Eq) or len(compare.comparators) != 1:
|
|
98
|
+
continue
|
|
99
|
+
for side in (compare.left, compare.comparators[0]):
|
|
100
|
+
if isinstance(side, ast.Name) and side.id in imported:
|
|
101
|
+
asserted.add(side.id)
|
|
102
|
+
return asserted
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@final
|
|
106
|
+
class ProductionDerivedTestCases(Rule):
|
|
107
|
+
id = "production-derived-test-cases"
|
|
108
|
+
code = "SARJ409"
|
|
109
|
+
documentation = RuleDocumentation(
|
|
110
|
+
summary="Parametrized test cases come from the production collection whose membership they should protect.",
|
|
111
|
+
rationale=(
|
|
112
|
+
"Removing a production member can remove the corresponding test case too, allowing the same defect to "
|
|
113
|
+
"weaken both the implementation and its supposed oracle."
|
|
114
|
+
),
|
|
115
|
+
remediation="Define an independent expected table, assert production equals it, and drive behavior from that table.",
|
|
116
|
+
category=RuleCategory.TESTING,
|
|
117
|
+
autofix=AutofixPolicy.NONE,
|
|
118
|
+
limitations=(
|
|
119
|
+
"Only direct imported collections, simple collection wrappers, and direct set expressions in pytest parametrization are checked.",
|
|
120
|
+
"Local expected tables, test-helper fixtures, enum exhaustiveness, names ending in `_REGISTRY`, and collections whose exact membership is asserted elsewhere in the module are excluded.",
|
|
121
|
+
"Project-wide relationships hidden behind helpers require judgment and remain unreported.",
|
|
122
|
+
),
|
|
123
|
+
examples=(
|
|
124
|
+
RuleExample(
|
|
125
|
+
example_id="independent-model-cases",
|
|
126
|
+
title="Drive behavior from an independent expected table",
|
|
127
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
128
|
+
files=(
|
|
129
|
+
ExampleFile.python(
|
|
130
|
+
"tests/test_models.py",
|
|
131
|
+
"EXPECTED_MODELS = ('a', 'b')\n\n@pytest.mark.parametrize('model', EXPECTED_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
focus_path=PurePosixPath("tests/test_models.py"),
|
|
135
|
+
expected_count=0,
|
|
136
|
+
public=True,
|
|
137
|
+
),
|
|
138
|
+
RuleExample(
|
|
139
|
+
example_id="production-derived-model-cases",
|
|
140
|
+
title="Do not derive cases from production eligibility",
|
|
141
|
+
outcome=ExampleOutcome.MATCH,
|
|
142
|
+
files=(
|
|
143
|
+
ExampleFile.python(
|
|
144
|
+
"tests/test_models.py",
|
|
145
|
+
"from app.models import ELIGIBLE_MODELS\n\n@pytest.mark.parametrize('model', ELIGIBLE_MODELS)\ndef test_model(model):\n assert build(model).tier == 'priority'\n",
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
focus_path=PurePosixPath("tests/test_models.py"),
|
|
149
|
+
expected_count=1,
|
|
150
|
+
public=True,
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
description = documentation.summary
|
|
155
|
+
|
|
156
|
+
@override
|
|
157
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
158
|
+
if not is_test_path(path):
|
|
159
|
+
return []
|
|
160
|
+
tree = parse_or_none(path, source)
|
|
161
|
+
if tree is None:
|
|
162
|
+
return []
|
|
163
|
+
imported = _imported_bindings(tree)
|
|
164
|
+
independently_asserted = _independently_asserted_collections(tree, imported)
|
|
165
|
+
findings: list[Diagnostic] = []
|
|
166
|
+
for node in ast.walk(tree):
|
|
167
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) or not node.name.startswith("test_"):
|
|
168
|
+
continue
|
|
169
|
+
for decorator in node.decorator_list:
|
|
170
|
+
cases = _parametrize_cases(decorator)
|
|
171
|
+
if cases is None:
|
|
172
|
+
continue
|
|
173
|
+
collection = _direct_imported_collection(cases, imported)
|
|
174
|
+
if collection is None or collection.id in independently_asserted:
|
|
175
|
+
continue
|
|
176
|
+
findings.append(
|
|
177
|
+
Diagnostic(
|
|
178
|
+
path=path,
|
|
179
|
+
line=decorator.lineno,
|
|
180
|
+
col=decorator.col_offset + 1,
|
|
181
|
+
code=self.code,
|
|
182
|
+
severity=Severity.WARNING,
|
|
183
|
+
message=(
|
|
184
|
+
f"`{collection.id}` supplies the cases from production, so removing a member can remove "
|
|
185
|
+
"the test that should catch the regression; define an independent expected case table and "
|
|
186
|
+
"assert the production collection equals it."
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
findings.sort(key=lambda finding: (finding.line, finding.col))
|
|
191
|
+
return findings
|