sarj-python-lint 0.82.2__tar.gz → 0.82.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_registry.py +4 -0
  4. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +2 -2
  5. sarj_python_lint-0.82.4/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +281 -0
  6. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/.gitignore +0 -0
  7. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/LICENSE +0 -0
  8. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/README.md +0 -0
  9. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/__init__.py +0 -0
  10. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/__main__.py +0 -0
  11. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/_analysis_session.py +0 -0
  12. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/_python_target.py +0 -0
  14. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  27. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_imports.py +0 -0
  28. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_logging.py +0 -0
  29. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_paths.py +0 -0
  30. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_project_index.py +0 -0
  31. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  32. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_pytest.py +0 -0
  33. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_sql.py +0 -0
  34. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  35. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  36. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  37. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  38. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  39. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  40. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  41. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  42. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  43. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  44. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  45. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  46. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  47. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  48. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  49. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  50. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  51. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  53. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  54. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  55. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  56. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  57. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  58. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  59. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  60. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  61. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  62. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  64. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  65. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  66. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  67. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  68. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  69. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  70. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  71. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  72. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  73. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  74. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  75. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  76. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  77. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  78. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  79. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  80. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  81. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  82. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  83. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  84. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  85. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  86. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  87. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  88. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  89. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  90. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  91. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  92. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  93. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  94. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  95. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  96. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  97. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  98. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  99. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  100. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  101. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  102. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  103. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  104. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  105. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  106. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  107. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  108. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  109. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  110. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  111. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  112. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  113. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  114. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  115. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  116. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  117. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  118. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  119. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  120. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  121. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  122. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  123. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/stepdown.py +0 -0
  124. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  125. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  126. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  127. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  128. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  129. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  130. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  131. {sarj_python_lint-0.82.2 → sarj_python_lint-0.82.4}/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.2
3
+ Version: 0.82.4
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.82.2"
3
+ version = "0.82.4"
4
4
  description = "Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "sarj-ai" }]
@@ -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
@@ -200,6 +203,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
200
203
  RequirePydanticForExternalJson.id: RequirePydanticForExternalJson,
201
204
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
202
205
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
206
+ PreferInjectedDependencyOverMonkeypatch.id: PreferInjectedDependencyOverMonkeypatch,
203
207
  MockWithoutSpec.id: MockWithoutSpec,
204
208
  OpaqueParametrizeCaseNeedsId.id: OpaqueParametrizeCaseNeedsId,
205
209
  PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
@@ -166,7 +166,7 @@ class NoExcessiveCognitiveComplexity(Rule):
166
166
  documentation = RuleDocumentation(
167
167
  summary=f"Error on cognitive complexity above {_ERROR_COMPLEXITY}; scores up to 20 pass.",
168
168
  rationale="Nested control flow increases the context a reader must retain while following a function.",
169
- remediation="Examples are fictional and written for this documentation; domain types are omitted. The library example replaces nested eligibility checks with early returns while preserving evaluation order. Refactor one function at a time, starting with its largest contributors. Use guard clauses, named predicates, or cohesive helpers; use lookup tables only for equivalent pure dispatch. Preserve APIs, side effects, evaluation order, and exception behavior. Run relevant tests before and after; remeasure the original and extracted functions. Do not hide branches in dense expressions or add indirection just to lower a score.",
169
+ remediation="Examples are fictional and written for this documentation; domain types are omitted. The library example replaces nested eligibility checks with early returns while preserving evaluation order. Refactor one function at a time, starting with its largest contributors. First simplify control flow and reduce nesting within the function. Extract a helper only when it clarifies a cohesive responsibility or enables meaningful reuse; do not split code solely to lower the score. Use lookup tables only for equivalent pure dispatch. Preserve APIs, side effects, evaluation order, and exception behavior. Run relevant tests before and after; remeasure the original and extracted functions. Do not hide branches in dense expressions or add indirection just to lower a score.",
170
170
  category=RuleCategory.MAINTAINABILITY,
171
171
  limitations=(
172
172
  "Sarj metric, not exact Sonar compatibility: functions and lambdas are independent; calls, recursion, function annotations, parameter defaults, and decorators are not scored.",
@@ -255,7 +255,7 @@ class NoExcessiveCognitiveComplexity(Rule):
255
255
  line=function.lineno,
256
256
  col=function.col_offset + 1,
257
257
  code=self.code,
258
- message=f"Cognitive complexity {score} exceeds {_ERROR_COMPLEXITY}. Largest contributors: {detail}. Start with the largest contributors: flatten nesting or extract a clearly named operation without hiding branches in dense expressions. Preserve behavior and public APIs, run relevant tests before and after, then remeasure.",
258
+ message=f"Cognitive complexity {score} exceeds {_ERROR_COMPLEXITY}. Largest contributors: {detail}. Start with the largest contributors: simplify control flow and flatten nesting. Extract a helper only when it clarifies a cohesive responsibility or enables meaningful reuse, never solely to lower the score. Do not hide branches in dense expressions. Preserve behavior and public APIs, run relevant tests before and after, then remeasure.",
259
259
  severity=Severity.ERROR,
260
260
  )
261
261
  )
@@ -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))))