sarj-python-lint 0.82.3__tar.gz → 0.82.5__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.82.3 → sarj_python_lint-0.82.5}/PKG-INFO +1 -1
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/pyproject.toml +1 -1
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_registry.py +8 -0
- sarj_python_lint-0.82.5/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +281 -0
- sarj_python_lint-0.82.5/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +367 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/.gitignore +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/LICENSE +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/README.md +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_python_target.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/unused_test_factory_option.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.82.
|
|
3
|
+
Version: 0.82.5
|
|
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/
|
|
@@ -93,6 +93,9 @@ from sarj_python_lint.rules.prefer_fstring_over_concat import PreferFstringOverC
|
|
|
93
93
|
from sarj_python_lint.rules.prefer_immutable_module_constant import (
|
|
94
94
|
PreferImmutableModuleConstant,
|
|
95
95
|
)
|
|
96
|
+
from sarj_python_lint.rules.prefer_injected_dependency_over_monkeypatch import (
|
|
97
|
+
PreferInjectedDependencyOverMonkeypatch,
|
|
98
|
+
)
|
|
96
99
|
from sarj_python_lint.rules.prefer_library_fake import PreferLibraryFake
|
|
97
100
|
from sarj_python_lint.rules.prefer_match_assert_never import PreferMatchAssertNever
|
|
98
101
|
from sarj_python_lint.rules.prefer_match_type_dispatch import PreferMatchTypeDispatch
|
|
@@ -100,6 +103,9 @@ from sarj_python_lint.rules.prefer_match_value_dispatch import PreferMatchValueD
|
|
|
100
103
|
from sarj_python_lint.rules.prefer_module_level_constant import (
|
|
101
104
|
PreferModuleLevelConstant,
|
|
102
105
|
)
|
|
106
|
+
from sarj_python_lint.rules.prefer_monkeypatch_for_process_state_in_test import (
|
|
107
|
+
PreferMonkeypatchForProcessStateInTest,
|
|
108
|
+
)
|
|
103
109
|
from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
|
|
104
110
|
PreferNamedtupleOverTupleReturn,
|
|
105
111
|
)
|
|
@@ -200,6 +206,8 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
|
200
206
|
RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
|
|
201
207
|
PreferModuleLevelConstant.id: PreferModuleLevelConstant,
|
|
202
208
|
PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
|
|
209
|
+
PreferInjectedDependencyOverMonkeypatch.id: PreferInjectedDependencyOverMonkeypatch,
|
|
210
|
+
PreferMonkeypatchForProcessStateInTest.id: PreferMonkeypatchForProcessStateInTest,
|
|
203
211
|
MockWithoutSpec.id: MockWithoutSpec,
|
|
204
212
|
OpaqueParametrizeCaseNeedsId.id: OpaqueParametrizeCaseNeedsId,
|
|
205
213
|
PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
|
sarj_python_lint-0.82.5/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
AutofixPolicy,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
Severity,
|
|
17
|
+
parse_or_none,
|
|
18
|
+
)
|
|
19
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
20
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_ATTRIBUTE_MUTATIONS = frozenset({"delattr", "setattr"})
|
|
29
|
+
_MONKEYPATCH = "monkeypatch"
|
|
30
|
+
_PYTEST = frozenset({"pytest"})
|
|
31
|
+
_SCOPES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PreferInjectedDependencyOverMonkeypatch(Rule):
|
|
35
|
+
id: str = "prefer-injected-dependency-over-monkeypatch"
|
|
36
|
+
code: str = "SARJ445"
|
|
37
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
38
|
+
summary="Tests should inject dependencies instead of replacing attributes through pytest monkeypatch.",
|
|
39
|
+
rationale=(
|
|
40
|
+
"Attribute patching hides collaborators and configuration behind ambient module or object state, coupling "
|
|
41
|
+
"tests to lookup locations instead of an explicit boundary. Pytest monkeypatch remains appropriate for "
|
|
42
|
+
"reversible process state such as environment variables and the working directory."
|
|
43
|
+
),
|
|
44
|
+
remediation=(
|
|
45
|
+
"Pass the collaborator, settings, clock, sleeper, random source, client, or factory explicitly; prefer a "
|
|
46
|
+
"real in-process dependency, framework override, or purpose-built ABC/Protocol fake. If a runtime "
|
|
47
|
+
"boundary is inherently global, or intercepting its lookup is the behavior under test, suppress SARJ445 "
|
|
48
|
+
"at that call with the concrete reason an owned injection seam would invalidate the test."
|
|
49
|
+
),
|
|
50
|
+
category=RuleCategory.TESTING,
|
|
51
|
+
autofix=AutofixPolicy.NONE,
|
|
52
|
+
limitations=(
|
|
53
|
+
"Only maintained test paths are analyzed; generated files and production helpers are excluded.",
|
|
54
|
+
"The rule recognizes pytest's conventional monkeypatch fixture name and parameters annotated as pytest.MonkeyPatch.",
|
|
55
|
+
"Environment, mapping, import-path, and working-directory mutations are intentionally allowed.",
|
|
56
|
+
"A nested function that captures a monkeypatch handle from an outer scope is not inferred.",
|
|
57
|
+
),
|
|
58
|
+
examples=(
|
|
59
|
+
RuleExample(
|
|
60
|
+
example_id="patched-first-party-collaborator",
|
|
61
|
+
title="Test replaces an application collaborator",
|
|
62
|
+
outcome=ExampleOutcome.MATCH,
|
|
63
|
+
files=(
|
|
64
|
+
ExampleFile.python(
|
|
65
|
+
"tests/test_checkout.py",
|
|
66
|
+
"def test_checkout(monkeypatch):\n"
|
|
67
|
+
" monkeypatch.setattr(checkout, 'charge_card', lambda _card: True)\n"
|
|
68
|
+
" assert checkout.run() == 'paid'\n",
|
|
69
|
+
),
|
|
70
|
+
),
|
|
71
|
+
focus_path=PurePosixPath("tests/test_checkout.py"),
|
|
72
|
+
expected_count=1,
|
|
73
|
+
public=True,
|
|
74
|
+
),
|
|
75
|
+
RuleExample(
|
|
76
|
+
example_id="injected-recording-collaborator",
|
|
77
|
+
title="Test injects a purpose-built collaborator",
|
|
78
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
79
|
+
files=(
|
|
80
|
+
ExampleFile.python(
|
|
81
|
+
"tests/test_checkout.py",
|
|
82
|
+
"def test_checkout(recording_gateway):\n"
|
|
83
|
+
" service = CheckoutService(payment_gateway=recording_gateway)\n"
|
|
84
|
+
" assert service.run() == 'paid'\n",
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
focus_path=PurePosixPath("tests/test_checkout.py"),
|
|
88
|
+
expected_count=0,
|
|
89
|
+
public=True,
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
description: str = documentation.summary
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
97
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
98
|
+
return []
|
|
99
|
+
tree = parse_or_none(path, source)
|
|
100
|
+
if tree is None:
|
|
101
|
+
return []
|
|
102
|
+
imports = ImportIndex.from_tree(tree, module_scope_only=True)
|
|
103
|
+
diagnostics = [
|
|
104
|
+
Diagnostic(
|
|
105
|
+
path=path,
|
|
106
|
+
line=call.lineno,
|
|
107
|
+
col=call.col_offset + 1,
|
|
108
|
+
code=self.code,
|
|
109
|
+
message=(
|
|
110
|
+
f"`monkeypatch.{_operation(call)}` replaces an attribute through ambient state. Inject the "
|
|
111
|
+
"dependency or configuration, use a framework override, or provide a purpose-built fake. "
|
|
112
|
+
"If global lookup or interception is the behavior under test, suppress SARJ445 locally and explain "
|
|
113
|
+
"why injection would invalidate the test."
|
|
114
|
+
),
|
|
115
|
+
severity=Severity.WARNING,
|
|
116
|
+
)
|
|
117
|
+
for function in _functions(tree)
|
|
118
|
+
for call in _attribute_mutations(function, imports)
|
|
119
|
+
]
|
|
120
|
+
diagnostics.sort(key=lambda diagnostic: (diagnostic.line, diagnostic.col))
|
|
121
|
+
return diagnostics
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _operation(call: ast.Call) -> str:
|
|
125
|
+
func = call.func
|
|
126
|
+
if not isinstance(func, ast.Attribute): # pragma: no cover - guarded by _attribute_mutations
|
|
127
|
+
msg = "attribute mutation call must use an attribute function"
|
|
128
|
+
raise TypeError(msg)
|
|
129
|
+
return func.attr
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _functions(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
133
|
+
for node in ast.walk(tree):
|
|
134
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
135
|
+
yield node
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _attribute_mutations(
|
|
139
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
140
|
+
imports: ImportIndex,
|
|
141
|
+
) -> list[ast.Call]:
|
|
142
|
+
parameters = _parameter_handles(function, imports)
|
|
143
|
+
if not parameters:
|
|
144
|
+
return []
|
|
145
|
+
nodes = tuple(_lexical_body_nodes(function))
|
|
146
|
+
handles = set(parameters)
|
|
147
|
+
handles |= _direct_aliases(nodes, handles, parameters)
|
|
148
|
+
return [
|
|
149
|
+
node
|
|
150
|
+
for node in nodes
|
|
151
|
+
if isinstance(node, ast.Call)
|
|
152
|
+
and isinstance(node.func, ast.Attribute)
|
|
153
|
+
and node.func.attr in _ATTRIBUTE_MUTATIONS
|
|
154
|
+
and isinstance(node.func.value, ast.Name)
|
|
155
|
+
and node.func.value.id in handles
|
|
156
|
+
and not _parameter_rebound_before(node, node.func.value.id, nodes, parameters)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _parameter_handles(
|
|
161
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
162
|
+
imports: ImportIndex,
|
|
163
|
+
) -> set[str]:
|
|
164
|
+
args = function.args
|
|
165
|
+
parameters = (*args.posonlyargs, *args.args, *args.kwonlyargs)
|
|
166
|
+
return {
|
|
167
|
+
parameter.arg
|
|
168
|
+
for parameter in parameters
|
|
169
|
+
if parameter.arg == _MONKEYPATCH or _is_monkeypatch_annotation(parameter.annotation, imports)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_monkeypatch_annotation(annotation: ast.expr | None, imports: ImportIndex) -> bool:
|
|
174
|
+
if annotation is None:
|
|
175
|
+
return False
|
|
176
|
+
if imports.resolves(annotation, sources=_PYTEST, symbol="MonkeyPatch"):
|
|
177
|
+
return True
|
|
178
|
+
if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
|
|
179
|
+
return _is_monkeypatch_annotation(annotation.left, imports) or _is_monkeypatch_annotation(
|
|
180
|
+
annotation.right, imports
|
|
181
|
+
)
|
|
182
|
+
if isinstance(annotation, ast.Constant) and isinstance(annotation.value, str):
|
|
183
|
+
return annotation.value in {"MonkeyPatch", "pytest.MonkeyPatch"}
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parameter_rebound_before(
|
|
188
|
+
use: ast.AST,
|
|
189
|
+
handle: str,
|
|
190
|
+
nodes: tuple[ast.AST, ...],
|
|
191
|
+
parameters: set[str],
|
|
192
|
+
) -> bool:
|
|
193
|
+
if handle not in parameters:
|
|
194
|
+
return False
|
|
195
|
+
use_position = _position(use)
|
|
196
|
+
return any(handle in _bound_targets(node) and _position(node) < use_position for node in nodes)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _position(node: ast.AST) -> tuple[int, int]:
|
|
200
|
+
if isinstance(node, ast.withitem):
|
|
201
|
+
return _position(node.context_expr)
|
|
202
|
+
return (getattr(node, "lineno", 0), getattr(node, "col_offset", 0))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _direct_aliases(nodes: tuple[ast.AST, ...], handles: set[str], parameters: set[str]) -> set[str]:
|
|
206
|
+
binding_counts: dict[str, int] = {}
|
|
207
|
+
for node in nodes:
|
|
208
|
+
for name in _bound_targets(node):
|
|
209
|
+
binding_counts[name] = binding_counts.get(name, 0) + 1
|
|
210
|
+
aliases: set[str] = set()
|
|
211
|
+
changed = True
|
|
212
|
+
while changed:
|
|
213
|
+
changed = False
|
|
214
|
+
known = handles | aliases
|
|
215
|
+
for node in nodes:
|
|
216
|
+
binding = _alias_binding(node, known)
|
|
217
|
+
if binding is None:
|
|
218
|
+
continue
|
|
219
|
+
candidate, source = binding
|
|
220
|
+
source_was_rebound = _parameter_rebound_before(node, source, nodes, parameters)
|
|
221
|
+
if not source_was_rebound and binding_counts.get(candidate) == 1 and candidate not in known:
|
|
222
|
+
aliases.add(candidate)
|
|
223
|
+
changed = True
|
|
224
|
+
return aliases
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _bound_targets(node: ast.AST) -> set[str]:
|
|
228
|
+
match node:
|
|
229
|
+
case ast.Assign(targets=targets) | ast.Delete(targets=targets):
|
|
230
|
+
return {name for target in targets for name in _target_names(target)}
|
|
231
|
+
case (
|
|
232
|
+
ast.AnnAssign(target=target)
|
|
233
|
+
| ast.AugAssign(target=target)
|
|
234
|
+
| ast.NamedExpr(target=target)
|
|
235
|
+
| ast.For(target=target)
|
|
236
|
+
| ast.AsyncFor(target=target)
|
|
237
|
+
):
|
|
238
|
+
return _target_names(target)
|
|
239
|
+
case ast.withitem(optional_vars=target) if target is not None:
|
|
240
|
+
return _target_names(target)
|
|
241
|
+
case _:
|
|
242
|
+
return set()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _alias_binding(node: ast.AST, handles: set[str]) -> tuple[str, str] | None:
|
|
246
|
+
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
247
|
+
value = node.value
|
|
248
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
249
|
+
if isinstance(value, ast.Name) and value.id in handles and len(targets) == 1:
|
|
250
|
+
names = _target_names(targets[0])
|
|
251
|
+
return (next(iter(names)), value.id) if len(names) == 1 else None
|
|
252
|
+
if isinstance(node, ast.withitem) and node.optional_vars is not None:
|
|
253
|
+
expression = node.context_expr
|
|
254
|
+
if (
|
|
255
|
+
isinstance(expression, ast.Call)
|
|
256
|
+
and isinstance(expression.func, ast.Attribute)
|
|
257
|
+
and expression.func.attr == "context"
|
|
258
|
+
and isinstance(expression.func.value, ast.Name)
|
|
259
|
+
and expression.func.value.id in handles
|
|
260
|
+
):
|
|
261
|
+
names = _target_names(node.optional_vars)
|
|
262
|
+
return (next(iter(names)), expression.func.value.id) if len(names) == 1 else None
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _target_names(node: ast.expr) -> set[str]:
|
|
267
|
+
if isinstance(node, ast.Name):
|
|
268
|
+
return {node.id}
|
|
269
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
270
|
+
return {name for element in node.elts for name in _target_names(element)}
|
|
271
|
+
return set()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _lexical_body_nodes(function: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
|
|
275
|
+
pending: list[ast.AST] = list(reversed(function.body))
|
|
276
|
+
while pending:
|
|
277
|
+
node = pending.pop()
|
|
278
|
+
yield node
|
|
279
|
+
if isinstance(node, _SCOPES):
|
|
280
|
+
continue
|
|
281
|
+
pending.extend(reversed(list(ast.iter_child_nodes(node))))
|
sarj_python_lint-0.82.5/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import PurePosixPath
|
|
5
|
+
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
|
|
6
|
+
|
|
7
|
+
from sarj_python_lint.rule_base import (
|
|
8
|
+
AutofixPolicy,
|
|
9
|
+
Diagnostic,
|
|
10
|
+
ExampleFile,
|
|
11
|
+
ExampleOutcome,
|
|
12
|
+
Rule,
|
|
13
|
+
RuleCategory,
|
|
14
|
+
RuleDocumentation,
|
|
15
|
+
RuleExample,
|
|
16
|
+
Severity,
|
|
17
|
+
parse_or_none,
|
|
18
|
+
)
|
|
19
|
+
from sarj_python_lint.rules._imports import ImportIndex
|
|
20
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterable, Iterator
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_OS = frozenset({"os"})
|
|
29
|
+
_SYS = frozenset({"sys"})
|
|
30
|
+
_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Mutation(NamedTuple):
|
|
34
|
+
node: ast.expr
|
|
35
|
+
family: str
|
|
36
|
+
subject: str
|
|
37
|
+
remedy: str
|
|
38
|
+
identity: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@final
|
|
42
|
+
class PreferMonkeypatchForProcessStateInTest(Rule):
|
|
43
|
+
id = "prefer-monkeypatch-for-process-state-in-test"
|
|
44
|
+
code = "SARJ446"
|
|
45
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
46
|
+
summary="Test mutates process-wide state without a restoring test scope.",
|
|
47
|
+
rationale=(
|
|
48
|
+
"The working directory and Python import registries are shared by every test in the process. A direct "
|
|
49
|
+
"mutation can survive an assertion or setup failure, making later tests depend on execution order."
|
|
50
|
+
),
|
|
51
|
+
remediation=(
|
|
52
|
+
"Use pytest's monkeypatch.chdir, monkeypatch.syspath_prepend, monkeypatch.setitem, or "
|
|
53
|
+
"monkeypatch.delitem so teardown restores the previous state even when the test fails. A deliberate "
|
|
54
|
+
"manual restoration may remain when it is enclosed by a matching try/finally."
|
|
55
|
+
),
|
|
56
|
+
category=RuleCategory.TESTING,
|
|
57
|
+
autofix=AutofixPolicy.NONE,
|
|
58
|
+
limitations=(
|
|
59
|
+
"Only maintained test paths are analyzed; generated files, production code, and module/class bootstrap mutations are excluded.",
|
|
60
|
+
"The rule covers os.chdir, sys.path.insert(0, ...), and direct sys.modules set, delete, or unused pop operations with an equivalent pytest restoring helper.",
|
|
61
|
+
"Environment variables remain owned by Ruff TID251/B003; argv, locale, timezone, warning filters, and process APIs without an equivalent helper are outside this rule.",
|
|
62
|
+
"Imports and import aliases are resolved conservatively. Indirect container aliases and dynamically selected modules are not inferred.",
|
|
63
|
+
),
|
|
64
|
+
examples=(
|
|
65
|
+
RuleExample(
|
|
66
|
+
example_id="direct-module-registry-mutation",
|
|
67
|
+
title="Test installs a module without guaranteed restoration",
|
|
68
|
+
outcome=ExampleOutcome.MATCH,
|
|
69
|
+
files=(
|
|
70
|
+
ExampleFile.python(
|
|
71
|
+
"tests/test_plugin.py",
|
|
72
|
+
"import sys\n\ndef test_plugin(fake_plugin):\n sys.modules['optional_plugin'] = fake_plugin\n",
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
focus_path=PurePosixPath("tests/test_plugin.py"),
|
|
76
|
+
expected_count=1,
|
|
77
|
+
public=True,
|
|
78
|
+
),
|
|
79
|
+
RuleExample(
|
|
80
|
+
example_id="scoped-module-registry-mutation",
|
|
81
|
+
title="Pytest restores the module registry after the test",
|
|
82
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
83
|
+
files=(
|
|
84
|
+
ExampleFile.python(
|
|
85
|
+
"tests/test_plugin.py",
|
|
86
|
+
"import sys\n\ndef test_plugin(monkeypatch, fake_plugin):\n"
|
|
87
|
+
" monkeypatch.setitem(sys.modules, 'optional_plugin', fake_plugin)\n",
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
focus_path=PurePosixPath("tests/test_plugin.py"),
|
|
91
|
+
expected_count=0,
|
|
92
|
+
public=True,
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
description = documentation.summary
|
|
97
|
+
|
|
98
|
+
@override
|
|
99
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
100
|
+
if not is_test_path(path) or is_generated(path, source):
|
|
101
|
+
return []
|
|
102
|
+
tree = parse_or_none(path, source)
|
|
103
|
+
if tree is None:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
module_imports = ImportIndex.from_tree(tree, module_scope_only=True)
|
|
107
|
+
mutations = [
|
|
108
|
+
mutation for function in _functions(tree) for mutation in _function_mutations(function, module_imports)
|
|
109
|
+
]
|
|
110
|
+
diagnostics = [
|
|
111
|
+
Diagnostic(
|
|
112
|
+
path=path,
|
|
113
|
+
line=mutation.node.lineno,
|
|
114
|
+
col=mutation.node.col_offset + 1,
|
|
115
|
+
code=self.code,
|
|
116
|
+
message=(
|
|
117
|
+
f"Direct {mutation.subject} mutation can leak into later tests when this scope exits early. "
|
|
118
|
+
f"Use {mutation.remedy} so pytest restores the previous process state automatically."
|
|
119
|
+
),
|
|
120
|
+
severity=Severity.WARNING,
|
|
121
|
+
)
|
|
122
|
+
for mutation in mutations
|
|
123
|
+
]
|
|
124
|
+
diagnostics.sort(key=lambda diagnostic: (diagnostic.line, diagnostic.col))
|
|
125
|
+
return diagnostics
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _functions(tree: ast.Module) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
129
|
+
for node in ast.walk(tree):
|
|
130
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
131
|
+
yield node
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _function_mutations(
|
|
135
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
136
|
+
module_imports: ImportIndex,
|
|
137
|
+
) -> list[_Mutation]:
|
|
138
|
+
local_tree = ast.Module(body=function.body, type_ignores=[])
|
|
139
|
+
local_imports = ImportIndex.from_tree(local_tree)
|
|
140
|
+
nodes = tuple(_lexical_nodes(function))
|
|
141
|
+
parents = {child: parent for parent in nodes for child in ast.iter_child_nodes(parent)}
|
|
142
|
+
mutations: list[_Mutation] = []
|
|
143
|
+
for node in nodes:
|
|
144
|
+
mutation = _mutation(node, parents, module_imports, local_imports)
|
|
145
|
+
if mutation is None or _import_root_rebound_before(function, mutation.node, module_imports, local_imports):
|
|
146
|
+
continue
|
|
147
|
+
mutations.append(mutation)
|
|
148
|
+
restored = _restored_mutations(nodes, mutations, module_imports, local_imports)
|
|
149
|
+
return [mutation for mutation in mutations if mutation.node not in restored]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _lexical_nodes(function: ast.FunctionDef | ast.AsyncFunctionDef) -> Iterator[ast.AST]:
|
|
153
|
+
stack: list[ast.AST] = [node for node in reversed(function.body) if not isinstance(node, _SCOPE_NODES)]
|
|
154
|
+
while stack:
|
|
155
|
+
node = stack.pop()
|
|
156
|
+
yield node
|
|
157
|
+
for child in reversed(list(ast.iter_child_nodes(node))):
|
|
158
|
+
if isinstance(child, _SCOPE_NODES):
|
|
159
|
+
continue
|
|
160
|
+
stack.append(child)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _mutation(
|
|
164
|
+
node: ast.AST,
|
|
165
|
+
parents: dict[ast.AST, ast.AST],
|
|
166
|
+
module_imports: ImportIndex,
|
|
167
|
+
local_imports: ImportIndex,
|
|
168
|
+
) -> _Mutation | None:
|
|
169
|
+
if isinstance(node, ast.Call):
|
|
170
|
+
mutation = _call_mutation(node, parents, module_imports, local_imports)
|
|
171
|
+
if mutation is not None:
|
|
172
|
+
return mutation
|
|
173
|
+
target: ast.expr | None = None
|
|
174
|
+
if isinstance(node, ast.Assign):
|
|
175
|
+
target = next((item for item in node.targets if _is_modules_item(item, module_imports, local_imports)), None)
|
|
176
|
+
elif isinstance(node, (ast.AnnAssign, ast.AugAssign)):
|
|
177
|
+
target = node.target
|
|
178
|
+
if target is not None and _is_modules_item(target, module_imports, local_imports):
|
|
179
|
+
return _Mutation(
|
|
180
|
+
target,
|
|
181
|
+
"modules",
|
|
182
|
+
"sys.modules",
|
|
183
|
+
"monkeypatch.setitem(sys.modules, key, value)",
|
|
184
|
+
_item_key(target),
|
|
185
|
+
)
|
|
186
|
+
if isinstance(node, ast.Delete):
|
|
187
|
+
target = next((item for item in node.targets if _is_modules_item(item, module_imports, local_imports)), None)
|
|
188
|
+
if target is not None:
|
|
189
|
+
return _Mutation(
|
|
190
|
+
target,
|
|
191
|
+
"modules",
|
|
192
|
+
"sys.modules",
|
|
193
|
+
"monkeypatch.delitem(sys.modules, key)",
|
|
194
|
+
_item_key(target),
|
|
195
|
+
)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _call_mutation(
|
|
200
|
+
call: ast.Call,
|
|
201
|
+
parents: dict[ast.AST, ast.AST],
|
|
202
|
+
module_imports: ImportIndex,
|
|
203
|
+
local_imports: ImportIndex,
|
|
204
|
+
) -> _Mutation | None:
|
|
205
|
+
if _resolves(call.func, module_imports, local_imports, sources=_OS, symbol="chdir"):
|
|
206
|
+
return _Mutation(call, "cwd", "working-directory", "monkeypatch.chdir(path)")
|
|
207
|
+
if (
|
|
208
|
+
isinstance(call.func, ast.Attribute)
|
|
209
|
+
and call.func.attr == "insert"
|
|
210
|
+
and _resolves(call.func.value, module_imports, local_imports, sources=_SYS, symbol="path")
|
|
211
|
+
and call.args
|
|
212
|
+
and _is_zero(call.args[0])
|
|
213
|
+
):
|
|
214
|
+
identity = ast.dump(call.args[1], include_attributes=False) if len(call.args) > 1 else None
|
|
215
|
+
return _Mutation(call, "path", "sys.path", "monkeypatch.syspath_prepend(path)", identity)
|
|
216
|
+
if (
|
|
217
|
+
isinstance(call.func, ast.Attribute)
|
|
218
|
+
and call.func.attr == "pop"
|
|
219
|
+
and _resolves(call.func.value, module_imports, local_imports, sources=_SYS, symbol="modules")
|
|
220
|
+
and isinstance(parents.get(call), ast.Expr)
|
|
221
|
+
):
|
|
222
|
+
identity = ast.dump(call.args[0], include_attributes=False) if call.args else None
|
|
223
|
+
return _Mutation(
|
|
224
|
+
call,
|
|
225
|
+
"modules",
|
|
226
|
+
"sys.modules",
|
|
227
|
+
"monkeypatch.delitem(sys.modules, key, raising=False)",
|
|
228
|
+
identity,
|
|
229
|
+
)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _is_modules_item(node: ast.expr, module_imports: ImportIndex, local_imports: ImportIndex) -> bool:
|
|
234
|
+
return isinstance(node, ast.Subscript) and _resolves(
|
|
235
|
+
node.value,
|
|
236
|
+
module_imports,
|
|
237
|
+
local_imports,
|
|
238
|
+
sources=_SYS,
|
|
239
|
+
symbol="modules",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _item_key(node: ast.expr) -> str | None:
|
|
244
|
+
return ast.dump(node.slice, include_attributes=False) if isinstance(node, ast.Subscript) else None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _is_zero(node: ast.expr) -> bool:
|
|
248
|
+
return isinstance(node, ast.Constant) and not isinstance(node.value, bool) and node.value == 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _resolves(
|
|
252
|
+
node: ast.expr,
|
|
253
|
+
module_imports: ImportIndex,
|
|
254
|
+
local_imports: ImportIndex,
|
|
255
|
+
*,
|
|
256
|
+
sources: frozenset[str],
|
|
257
|
+
symbol: str,
|
|
258
|
+
) -> bool:
|
|
259
|
+
return module_imports.resolves(node, sources=sources, symbol=symbol) or local_imports.resolves(
|
|
260
|
+
node,
|
|
261
|
+
sources=sources,
|
|
262
|
+
symbol=symbol,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _import_root_rebound_before(
|
|
267
|
+
function: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
268
|
+
use: ast.AST,
|
|
269
|
+
module_imports: ImportIndex,
|
|
270
|
+
local_imports: ImportIndex,
|
|
271
|
+
) -> bool:
|
|
272
|
+
roots = {
|
|
273
|
+
node.id
|
|
274
|
+
for node in ast.walk(use)
|
|
275
|
+
if isinstance(node, ast.Name) and (node.id in module_imports.bindings or node.id in local_imports.bindings)
|
|
276
|
+
}
|
|
277
|
+
parameters = {
|
|
278
|
+
argument.arg
|
|
279
|
+
for argument in (
|
|
280
|
+
*function.args.posonlyargs,
|
|
281
|
+
*function.args.args,
|
|
282
|
+
*function.args.kwonlyargs,
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
if not roots.isdisjoint(parameters):
|
|
286
|
+
return True
|
|
287
|
+
use_position = _position(use)
|
|
288
|
+
return any(
|
|
289
|
+
isinstance(node, ast.Name)
|
|
290
|
+
and isinstance(node.ctx, (ast.Store, ast.Del))
|
|
291
|
+
and node.id in roots
|
|
292
|
+
and _position(node) < use_position
|
|
293
|
+
for node in _lexical_nodes(function)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _position(node: ast.AST) -> tuple[int, int]:
|
|
298
|
+
return (getattr(node, "lineno", 0), getattr(node, "col_offset", 0))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _restored_mutations(
|
|
302
|
+
nodes: tuple[ast.AST, ...],
|
|
303
|
+
mutations: list[_Mutation],
|
|
304
|
+
module_imports: ImportIndex,
|
|
305
|
+
local_imports: ImportIndex,
|
|
306
|
+
) -> set[ast.AST]:
|
|
307
|
+
restored: set[ast.AST] = set()
|
|
308
|
+
for node in nodes:
|
|
309
|
+
if not isinstance(node, (ast.Try, ast.TryStar)) or not node.finalbody:
|
|
310
|
+
continue
|
|
311
|
+
restored.update(_restored_for_try(node, mutations, module_imports, local_imports))
|
|
312
|
+
return restored
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _restored_for_try(
|
|
316
|
+
node: ast.Try | ast.TryStar,
|
|
317
|
+
mutations: list[_Mutation],
|
|
318
|
+
module_imports: ImportIndex,
|
|
319
|
+
local_imports: ImportIndex,
|
|
320
|
+
) -> set[ast.AST]:
|
|
321
|
+
protected_nodes = set(_descendants(node.body))
|
|
322
|
+
final_nodes = set(_descendants(node.finalbody))
|
|
323
|
+
protected = [mutation for mutation in mutations if mutation.node in protected_nodes]
|
|
324
|
+
final = [mutation for mutation in mutations if mutation.node in final_nodes]
|
|
325
|
+
restored: set[ast.AST] = set()
|
|
326
|
+
for mutation in protected:
|
|
327
|
+
matching = next((item for item in final if _same_state(mutation, item)), None)
|
|
328
|
+
if matching is not None:
|
|
329
|
+
restored.update((mutation.node, matching.node))
|
|
330
|
+
continue
|
|
331
|
+
if mutation.family == "path" and _finalbody_removes_path(
|
|
332
|
+
node.finalbody,
|
|
333
|
+
mutation.identity,
|
|
334
|
+
module_imports,
|
|
335
|
+
local_imports,
|
|
336
|
+
):
|
|
337
|
+
restored.add(mutation.node)
|
|
338
|
+
return restored
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _descendants(statements: Iterable[ast.stmt]) -> Iterator[ast.AST]:
|
|
342
|
+
for statement in statements:
|
|
343
|
+
yield from ast.walk(statement)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _same_state(left: _Mutation, right: _Mutation) -> bool:
|
|
347
|
+
return left.family == right.family and (left.identity is None or left.identity == right.identity)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _finalbody_removes_path(
|
|
351
|
+
statements: list[ast.stmt],
|
|
352
|
+
identity: str | None,
|
|
353
|
+
module_imports: ImportIndex,
|
|
354
|
+
local_imports: ImportIndex,
|
|
355
|
+
) -> bool:
|
|
356
|
+
if identity is None:
|
|
357
|
+
return False
|
|
358
|
+
return any(
|
|
359
|
+
isinstance(node, ast.Call)
|
|
360
|
+
and isinstance(node.func, ast.Attribute)
|
|
361
|
+
and node.func.attr == "remove"
|
|
362
|
+
and _resolves(node.func.value, module_imports, local_imports, sources=_SYS, symbol="path")
|
|
363
|
+
and bool(node.args)
|
|
364
|
+
and ast.dump(node.args[0], include_attributes=False) == identity
|
|
365
|
+
for statement in statements
|
|
366
|
+
for node in ast.walk(statement)
|
|
367
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_analysis_session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_fixed_record.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_project_index.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_prose_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_test_assertions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_dunder_all.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_or_pattern.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.82.3 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|
|
File without changes
|