sarj-python-lint 0.82.4__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.
Files changed (132) hide show
  1. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_registry.py +4 -0
  4. sarj_python_lint-0.82.5/src/sarj_python_lint/rules/prefer_monkeypatch_for_process_state_in_test.py +367 -0
  5. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/.gitignore +0 -0
  6. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/LICENSE +0 -0
  7. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/README.md +0 -0
  8. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/__init__.py +0 -0
  9. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/__main__.py +0 -0
  10. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_analysis_session.py +0 -0
  11. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_filesystem.py +0 -0
  12. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_python_target.py +0 -0
  13. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  24. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_first_party.py +0 -0
  25. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_fixed_record.py +0 -0
  26. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_paths.py +0 -0
  29. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_project_index.py +0 -0
  30. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  31. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_pytest.py +0 -0
  32. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_sql.py +0 -0
  33. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  34. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  35. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/complex_postgres_query_requires_architecture_review.py +0 -0
  36. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
  37. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/excessive_commentary.py +0 -0
  40. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
  41. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  43. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  44. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  45. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  46. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
  47. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  48. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
  49. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
  50. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  51. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_deleted_only_override_parameter.py +0 -0
  52. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_dunder_all.py +0 -0
  53. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  54. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_excessive_cognitive_complexity.py +0 -0
  55. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
  56. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
  57. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  58. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  59. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  60. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  61. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
  62. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  63. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_positional_psycopg_row_escape.py +0 -0
  64. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_psycopg_execution_outside_injected_owner.py +0 -0
  65. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
  66. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +0 -0
  67. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_raw_source_text_test_oracle.py +0 -0
  68. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
  69. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_redundant_module_alias_exports.py +0 -0
  70. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  71. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
  72. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  73. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  74. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  75. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_service_behavior_in_settings.py +0 -0
  76. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  77. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  78. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  79. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  80. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  81. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  82. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/no_whole_request_response_payload_in_log.py +0 -0
  83. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  84. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  85. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  86. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  87. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
  88. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  89. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  90. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  91. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_injected_dependency_over_monkeypatch.py +0 -0
  92. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  93. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  94. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  95. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_match_value_dispatch.py +0 -0
  96. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  97. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  98. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  99. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  100. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  101. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  102. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  103. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
  104. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  105. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  106. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +0 -0
  107. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  108. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  109. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  110. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  111. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  112. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
  113. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  114. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  115. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  116. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
  117. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  118. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  119. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
  120. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  121. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  122. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  123. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  124. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/stepdown.py +0 -0
  125. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
  126. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  127. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
  128. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  129. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/typed_error_reasons.py +0 -0
  130. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  131. {sarj_python_lint-0.82.4 → sarj_python_lint-0.82.5}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  132. {sarj_python_lint-0.82.4 → 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.4
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.82.4"
3
+ version = "0.82.5"
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" }]
@@ -103,6 +103,9 @@ from sarj_python_lint.rules.prefer_match_value_dispatch import PreferMatchValueD
103
103
  from sarj_python_lint.rules.prefer_module_level_constant import (
104
104
  PreferModuleLevelConstant,
105
105
  )
106
+ from sarj_python_lint.rules.prefer_monkeypatch_for_process_state_in_test import (
107
+ PreferMonkeypatchForProcessStateInTest,
108
+ )
106
109
  from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
107
110
  PreferNamedtupleOverTupleReturn,
108
111
  )
@@ -204,6 +207,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
204
207
  PreferModuleLevelConstant.id: PreferModuleLevelConstant,
205
208
  PreferImmutableModuleConstant.id: PreferImmutableModuleConstant,
206
209
  PreferInjectedDependencyOverMonkeypatch.id: PreferInjectedDependencyOverMonkeypatch,
210
+ PreferMonkeypatchForProcessStateInTest.id: PreferMonkeypatchForProcessStateInTest,
207
211
  MockWithoutSpec.id: MockWithoutSpec,
208
212
  OpaqueParametrizeCaseNeedsId.id: OpaqueParametrizeCaseNeedsId,
209
213
  PytestFixtureReturnsBareTuple.id: PytestFixtureReturnsBareTuple,
@@ -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
+ )