sarj-python-lint 0.75.0__tar.gz → 0.76.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_registry.py +10 -0
  4. sarj_python_lint-0.76.0/src/sarj_python_lint/rules/no_conftest_test_module_import.py +105 -0
  5. sarj_python_lint-0.76.0/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +171 -0
  6. sarj_python_lint-0.76.0/src/sarj_python_lint/rules/no_redundant_literal_description.py +214 -0
  7. sarj_python_lint-0.76.0/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +283 -0
  8. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/.gitignore +0 -0
  9. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/LICENSE +0 -0
  10. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/README.md +0 -0
  11. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/__init__.py +0 -0
  12. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/__main__.py +0 -0
  13. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/_filesystem.py +0 -0
  14. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  15. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/_secret_names.py +0 -0
  16. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/_version.py +0 -0
  17. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/py.typed +0 -0
  18. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/ratchet.py +0 -0
  19. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rule_base.py +0 -0
  20. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  21. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  22. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  23. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  24. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  25. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  26. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_imports.py +0 -0
  27. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  28. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  29. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
  30. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  31. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  32. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  33. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  34. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  35. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/created_at_order_requires_tiebreaker.py +0 -0
  36. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  37. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  38. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  39. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  40. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  41. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  42. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  43. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/get_delegates_to_get_many.py +0 -0
  44. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
  45. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  46. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  47. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  48. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
  49. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  50. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  51. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  52. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  53. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  54. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  55. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  56. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  57. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  58. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  59. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  60. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  61. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  62. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  63. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  64. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  65. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  66. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  67. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  68. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  69. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  70. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  71. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  72. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  73. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
  74. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
  75. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
  76. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  77. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  78. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  79. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  80. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  81. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  82. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
  83. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  84. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  85. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  86. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  87. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  88. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  89. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  90. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -0
  91. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  92. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  93. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  94. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  95. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  96. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  97. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  98. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  99. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  100. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -0
  101. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -0
  102. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
  103. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  104. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  105. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  106. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  107. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
  108. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  109. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  110. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
  111. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
  112. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -0
  113. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  114. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
  115. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -0
  116. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  117. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  118. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  119. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  120. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
  121. {sarj_python_lint-0.75.0 → sarj_python_lint-0.76.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sarj-python-lint
3
- Version: 0.75.0
3
+ Version: 0.76.0
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.75.0"
3
+ version = "0.76.0"
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" }]
@@ -33,6 +33,7 @@ from sarj_python_lint.rules.no_aggregation_in_store_query import (
33
33
  NoAggregationInStoreQuery,
34
34
  )
35
35
  from sarj_python_lint.rules.no_comment_cruft import NoCommentCruft
36
+ from sarj_python_lint.rules.no_conftest_test_module_import import NoConftestTestModuleImport
36
37
  from sarj_python_lint.rules.no_cors_wildcard_with_credentials import (
37
38
  NoCorsWildcardWithCredentials,
38
39
  )
@@ -51,11 +52,13 @@ from sarj_python_lint.rules.no_hidden_constructor_fallback import (
51
52
  )
52
53
  from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
53
54
  from sarj_python_lint.rules.no_long_comment import NoLongComment
55
+ from sarj_python_lint.rules.no_nested_pydantic_field_validator import NoNestedPydanticFieldValidator
54
56
  from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
55
57
  from sarj_python_lint.rules.no_optional_tenant_predicate import (
56
58
  NoOptionalTenantPredicate,
57
59
  )
58
60
  from sarj_python_lint.rules.no_query_with_many_joins import NoQueryWithManyJoins
61
+ from sarj_python_lint.rules.no_redundant_literal_description import NoRedundantLiteralDescription
59
62
  from sarj_python_lint.rules.no_repeated_string_literal import NoRepeatedStringLiteral
60
63
  from sarj_python_lint.rules.no_restated_comment import NoRestatedComment
61
64
  from sarj_python_lint.rules.no_secret_in_log import NoSecretInLog
@@ -125,6 +128,9 @@ from sarj_python_lint.rules.repeated_static_call_cases import RepeatedStaticCall
125
128
  from sarj_python_lint.rules.require_keyword_only_swap_prone_params import (
126
129
  RequireKeywordOnlySwapProneParams,
127
130
  )
131
+ from sarj_python_lint.rules.require_nodecode_for_splitting_settings_field import (
132
+ RequireNoDecodeForSplittingSettingsField,
133
+ )
128
134
  from sarj_python_lint.rules.require_port_for_service import RequirePortForService
129
135
  from sarj_python_lint.rules.require_pydantic_for_external_json import (
130
136
  RequirePydanticForExternalJson,
@@ -169,6 +175,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
169
175
  PreferTimedeltaForDurations.id: PreferTimedeltaForDurations,
170
176
  PreferStructOverNamedtuple.id: PreferStructOverNamedtuple,
171
177
  NoCommentCruft.id: NoCommentCruft,
178
+ NoConftestTestModuleImport.id: NoConftestTestModuleImport,
172
179
  StoreInsertRequiresOnConflict.id: StoreInsertRequiresOnConflict,
173
180
  SourceCoupledTest.id: SourceCoupledTest,
174
181
  IacSourceCoupledTest.id: IacSourceCoupledTest,
@@ -226,6 +233,8 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
226
233
  RestatedTestDocstring.id: RestatedTestDocstring,
227
234
  TestPhaseLabelComment.id: TestPhaseLabelComment,
228
235
  NoLongComment.id: NoLongComment,
236
+ NoNestedPydanticFieldValidator.id: NoNestedPydanticFieldValidator,
237
+ NoRedundantLiteralDescription.id: NoRedundantLiteralDescription,
229
238
  NoTypedDocSections.id: NoTypedDocSections,
230
239
  NoUnnecessaryDocstring.id: NoUnnecessaryDocstring,
231
240
  PreferNominalIdTypes.id: PreferNominalIdTypes,
@@ -239,6 +248,7 @@ REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
239
248
  PreserveDeclaredNominalId.id: PreserveDeclaredNominalId,
240
249
  PreserveEnumTypes.id: PreserveEnumTypes,
241
250
  RequirePydanticOrdinalLowerBound.id: RequirePydanticOrdinalLowerBound,
251
+ RequireNoDecodeForSplittingSettingsField.id: RequireNoDecodeForSplittingSettingsField,
242
252
  NoVagueSuppressionDescription.id: NoVagueSuppressionDescription,
243
253
  }
244
254
  )
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, 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
+ parse_or_none,
17
+ )
18
+
19
+
20
+ if TYPE_CHECKING:
21
+ from pathlib import Path
22
+
23
+
24
+ @final
25
+ class NoConftestTestModuleImport(Rule):
26
+ id = "no-conftest-test-module-import"
27
+ code = "SARJ426"
28
+ documentation = RuleDocumentation(
29
+ summary="Do not import individual test modules from conftest.py.",
30
+ rationale=(
31
+ "pytest imports conftest for every test module in its scope; importing a specific test from conftest "
32
+ "turns that test into global collection infrastructure and creates cycles and order dependence."
33
+ ),
34
+ remediation="Move shared fixtures or helpers into conftest.py or a dedicated support module.",
35
+ category=RuleCategory.TESTING,
36
+ autofix=AutofixPolicy.NONE,
37
+ limitations=(
38
+ "Only files named conftest.py are inspected.",
39
+ "A test module is recognized only when an explicit module-path component begins with `test_` or ends with `_test`.",
40
+ ),
41
+ examples=(
42
+ RuleExample(
43
+ example_id="conftest-imports-test-module",
44
+ title="Conftest imports a specific test module",
45
+ outcome=ExampleOutcome.MATCH,
46
+ files=(ExampleFile.python("tests/conftest.py", "from tests.test_api import make_client\n"),),
47
+ focus_path=PurePosixPath("tests/conftest.py"),
48
+ expected_count=1,
49
+ public=True,
50
+ ),
51
+ RuleExample(
52
+ example_id="conftest-imports-support-module",
53
+ title="Conftest imports dedicated support code",
54
+ outcome=ExampleOutcome.NO_MATCH,
55
+ files=(ExampleFile.python("tests/conftest.py", "from tests.helpers import make_client\n"),),
56
+ focus_path=PurePosixPath("tests/conftest.py"),
57
+ expected_count=0,
58
+ public=True,
59
+ ),
60
+ ),
61
+ )
62
+ description = documentation.summary
63
+
64
+ @override
65
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
66
+ if path.name != "conftest.py":
67
+ return []
68
+ tree = parse_or_none(path, source)
69
+ if tree is None:
70
+ return []
71
+ diagnostics: list[Diagnostic] = []
72
+ for statement in tree.body:
73
+ targets: list[str] = []
74
+ match statement:
75
+ case ast.Import(names=names):
76
+ targets.extend(alias.name for alias in names if _module_has_test_leaf(alias.name))
77
+ case ast.ImportFrom(module=module, names=names):
78
+ if module is not None and _module_has_test_leaf(module):
79
+ targets.append(module)
80
+ case _:
81
+ continue
82
+ if not targets:
83
+ continue
84
+ diagnostics.append(
85
+ Diagnostic(
86
+ path=path,
87
+ line=statement.lineno,
88
+ col=statement.col_offset + 1,
89
+ code=self.code,
90
+ message=(
91
+ f"conftest.py imports test module `{targets[0]}`; move shared fixtures or helpers to "
92
+ "conftest or a dedicated support module."
93
+ ),
94
+ )
95
+ )
96
+ return diagnostics
97
+
98
+
99
+ def _module_has_test_leaf(module: str) -> bool:
100
+ parts = module.split(".")
101
+ return any(_is_test_module_name(part) for part in parts)
102
+
103
+
104
+ def _is_test_module_name(name: str) -> bool:
105
+ return name.startswith("test_") or name.endswith("_test")
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ from typing import TYPE_CHECKING, 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
+ parse_or_none,
17
+ )
18
+ from sarj_python_lint.rules._imports import ImportIndex
19
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from pathlib import Path
24
+
25
+
26
+ _PYDANTIC_BASE_MODEL_SOURCES = frozenset({"pydantic", "pydantic.main"})
27
+ _PYDANTIC_SOURCES = frozenset({"pydantic", "pydantic.functional_validators"})
28
+
29
+
30
+ @final
31
+ class NoNestedPydanticFieldValidator(Rule):
32
+ id = "no-nested-pydantic-field-validator"
33
+ code = "SARJ425"
34
+ documentation = RuleDocumentation(
35
+ summary="Do not place an outer Pydantic model's field validator inside a nested helper class.",
36
+ rationale=(
37
+ "A decorator indented into a nested class registers on that class, not on the outer Pydantic model, "
38
+ "so the declared field silently loses validation."
39
+ ),
40
+ remediation="Move the field-validator method into the outer model class.",
41
+ category=RuleCategory.CORRECTNESS,
42
+ autofix=AutofixPolicy.NONE,
43
+ limitations=(
44
+ "Only direct BaseModel subclasses and validators directly owned by one nested class are inspected.",
45
+ "Nested classes with bases are excluded because inherited Pydantic fields cannot be resolved locally.",
46
+ "Test and generated files are excluded.",
47
+ ),
48
+ examples=(
49
+ RuleExample(
50
+ example_id="validator-nested-in-config",
51
+ title="Validator accidentally belongs to Config",
52
+ outcome=ExampleOutcome.MATCH,
53
+ files=(
54
+ ExampleFile.python(
55
+ "app/models.py",
56
+ "from pydantic import BaseModel, field_validator\n\n"
57
+ "class Settings(BaseModel):\n"
58
+ " language: str\n"
59
+ " class Config:\n"
60
+ " extra = 'forbid'\n"
61
+ " @field_validator('language')\n"
62
+ " @classmethod\n"
63
+ " def normalize(cls, value):\n"
64
+ " return value.lower()\n",
65
+ ),
66
+ ),
67
+ focus_path=PurePosixPath("app/models.py"),
68
+ expected_count=1,
69
+ public=True,
70
+ ),
71
+ RuleExample(
72
+ example_id="validator-owned-by-model",
73
+ title="Validator belongs to the model",
74
+ outcome=ExampleOutcome.NO_MATCH,
75
+ files=(
76
+ ExampleFile.python(
77
+ "app/models.py",
78
+ "from pydantic import BaseModel, field_validator\n\n"
79
+ "class Settings(BaseModel):\n"
80
+ " language: str\n"
81
+ " @field_validator('language')\n"
82
+ " @classmethod\n"
83
+ " def normalize(cls, value):\n"
84
+ " return value.lower()\n",
85
+ ),
86
+ ),
87
+ focus_path=PurePosixPath("app/models.py"),
88
+ expected_count=0,
89
+ public=True,
90
+ ),
91
+ ),
92
+ )
93
+ description = documentation.summary
94
+
95
+ @override
96
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
97
+ if 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)
103
+ diagnostics: list[Diagnostic] = []
104
+ for outer in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)):
105
+ if not _is_direct_model(outer, imports):
106
+ continue
107
+ outer_fields = _direct_fields(outer)
108
+ for nested in (statement for statement in outer.body if isinstance(statement, ast.ClassDef)):
109
+ # A based nested class may be an indirect Pydantic model whose inherited
110
+ # fields are unavailable to this file-local analysis. Only base-less
111
+ # Config/helper classes provide a deterministic ownership mistake.
112
+ if nested.bases:
113
+ continue
114
+ nested_fields = _direct_fields(nested)
115
+ for function in (
116
+ statement
117
+ for statement in nested.body
118
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef))
119
+ ):
120
+ for decorator, fields in _field_validators(function, imports):
121
+ misplaced = sorted((fields & outer_fields) - nested_fields)
122
+ if not misplaced:
123
+ continue
124
+ diagnostics.append(
125
+ Diagnostic(
126
+ path=path,
127
+ line=decorator.lineno,
128
+ col=decorator.col_offset + 1,
129
+ code=self.code,
130
+ message=(
131
+ f"Validator for outer field(s) {', '.join(f'`{name}`' for name in misplaced)} "
132
+ f"is nested inside `{nested.name}`; move it to `{outer.name}`."
133
+ ),
134
+ )
135
+ )
136
+ return diagnostics
137
+
138
+
139
+ def _is_direct_model(node: ast.ClassDef, imports: ImportIndex) -> bool:
140
+ return len(node.bases) == 1 and imports.resolves(
141
+ node.bases[0], sources=_PYDANTIC_BASE_MODEL_SOURCES, symbol="BaseModel"
142
+ )
143
+
144
+
145
+ def _direct_fields(node: ast.ClassDef) -> frozenset[str]:
146
+ return frozenset(
147
+ statement.target.id
148
+ for statement in node.body
149
+ if isinstance(statement, ast.AnnAssign)
150
+ and isinstance(statement.target, ast.Name)
151
+ and not statement.target.id.startswith("_")
152
+ and statement.target.id != "model_config"
153
+ )
154
+
155
+
156
+ def _field_validators(
157
+ node: ast.FunctionDef | ast.AsyncFunctionDef, imports: ImportIndex
158
+ ) -> tuple[tuple[ast.Call, frozenset[str]], ...]:
159
+ validators: list[tuple[ast.Call, frozenset[str]]] = []
160
+ for decorator in node.decorator_list:
161
+ if not isinstance(decorator, ast.Call) or not imports.resolves(
162
+ decorator.func, sources=_PYDANTIC_SOURCES, symbol="field_validator"
163
+ ):
164
+ continue
165
+ fields = frozenset(
166
+ argument.value
167
+ for argument in decorator.args
168
+ if isinstance(argument, ast.Constant) and isinstance(argument.value, str)
169
+ )
170
+ validators.append((decorator, fields))
171
+ return tuple(validators)
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import PurePosixPath
5
+ import re
6
+ from typing import TYPE_CHECKING, final, override
7
+
8
+ from sarj_python_lint.rule_base import (
9
+ AutofixPolicy,
10
+ Diagnostic,
11
+ ExampleFile,
12
+ ExampleOutcome,
13
+ Rule,
14
+ RuleCategory,
15
+ RuleDocumentation,
16
+ RuleExample,
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 pathlib import Path
25
+
26
+
27
+ _PYDANTIC_BASE_MODEL_SOURCES = frozenset({"pydantic", "pydantic.main"})
28
+ _PYDANTIC_FIELD_SOURCES = frozenset({"pydantic", "pydantic.fields"})
29
+ _TYPING_SOURCES = frozenset({"typing", "typing_extensions"})
30
+ _ENUM_SOURCES = frozenset({"enum"})
31
+ _DOMAIN_ONLY = re.compile(r"^\s*(?:must|should)\s+be(?:\s+one\s+of)?\s+(?P<body>.+?)\s*[.!]?\s*$", re.IGNORECASE)
32
+ _QUOTED_VALUE = re.compile(r"(['\"])(?P<value>.+?)\1")
33
+
34
+
35
+ @final
36
+ class NoRedundantLiteralDescription(Rule):
37
+ id = "no-redundant-literal-description"
38
+ code = "SARJ423"
39
+ documentation = RuleDocumentation(
40
+ summary="Do not restate a Literal or Enum field's closed domain in its Pydantic description.",
41
+ rationale=(
42
+ "Pydantic already publishes literal and enum domains in JSON Schema; duplicate must-be prose can "
43
+ "contradict the generated contract after a value changes."
44
+ ),
45
+ remediation="Remove the domain-only description or replace it with rationale not encoded by the field type.",
46
+ category=RuleCategory.MAINTAINABILITY,
47
+ autofix=AutofixPolicy.NONE,
48
+ limitations=(
49
+ "Only direct fields on direct BaseModel subclasses are inspected.",
50
+ "Enum fields are inspected only when their direct string-valued enum class is declared in the same module.",
51
+ "Descriptions are reported only when they begin with a narrow 'must be' or 'should be' restatement.",
52
+ "Test and generated files are excluded.",
53
+ ),
54
+ examples=(
55
+ RuleExample(
56
+ example_id="literal-domain-restated",
57
+ title="Literal domain repeated in prose",
58
+ outcome=ExampleOutcome.MATCH,
59
+ files=(
60
+ ExampleFile.python(
61
+ "app/models.py",
62
+ "from typing import Literal\nfrom pydantic import BaseModel, Field\n\n"
63
+ "class Request(BaseModel):\n"
64
+ " kind: Literal['call'] = Field(description=\"Must be 'call'\")\n",
65
+ ),
66
+ ),
67
+ focus_path=PurePosixPath("app/models.py"),
68
+ expected_count=1,
69
+ public=True,
70
+ ),
71
+ RuleExample(
72
+ example_id="literal-rationale-described",
73
+ title="Description adds rationale",
74
+ outcome=ExampleOutcome.NO_MATCH,
75
+ files=(
76
+ ExampleFile.python(
77
+ "app/models.py",
78
+ "from typing import Literal\nfrom pydantic import BaseModel, Field\n\n"
79
+ "class Request(BaseModel):\n"
80
+ " kind: Literal['call'] = Field(description='Routes through the realtime provider.')\n",
81
+ ),
82
+ ),
83
+ focus_path=PurePosixPath("app/models.py"),
84
+ expected_count=0,
85
+ public=True,
86
+ ),
87
+ ),
88
+ )
89
+ description = documentation.summary
90
+
91
+ @override
92
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
93
+ if is_test_path(path) or is_generated(path, source):
94
+ return []
95
+ tree = parse_or_none(path, source)
96
+ if tree is None:
97
+ return []
98
+ imports = ImportIndex.from_tree(tree)
99
+ enum_domains = _local_enum_domains(tree, imports)
100
+ diagnostics: list[Diagnostic] = []
101
+ for model in (node for node in ast.walk(tree) if isinstance(node, ast.ClassDef)):
102
+ if not _is_direct_model(model, imports):
103
+ continue
104
+ for statement in model.body:
105
+ if not isinstance(statement, ast.AnnAssign) or not isinstance(statement.target, ast.Name):
106
+ continue
107
+ domain = _closed_domain(statement.annotation, imports, enum_domains)
108
+ if domain is None:
109
+ continue
110
+ for field_call in _field_calls(statement, imports):
111
+ description = next(
112
+ (keyword.value for keyword in field_call.keywords if keyword.arg == "description"), None
113
+ )
114
+ if not (
115
+ isinstance(description, ast.Constant)
116
+ and isinstance(description.value, str)
117
+ and _repeats_domain(description.value, domain)
118
+ ):
119
+ continue
120
+ diagnostics.append(
121
+ Diagnostic(
122
+ path=path,
123
+ line=description.lineno,
124
+ col=description.col_offset + 1,
125
+ code=self.code,
126
+ message=(
127
+ f"`{statement.target.id}` repeats its closed type domain in `description`; "
128
+ "remove the restatement or document rationale not present in the schema."
129
+ ),
130
+ )
131
+ )
132
+ return diagnostics
133
+
134
+
135
+ def _is_direct_model(node: ast.ClassDef, imports: ImportIndex) -> bool:
136
+ return len(node.bases) == 1 and imports.resolves(
137
+ node.bases[0], sources=_PYDANTIC_BASE_MODEL_SOURCES, symbol="BaseModel"
138
+ )
139
+
140
+
141
+ def _local_enum_domains(tree: ast.Module, imports: ImportIndex) -> dict[str, frozenset[str]]:
142
+ domains: dict[str, frozenset[str]] = {}
143
+ for node in tree.body:
144
+ if not isinstance(node, ast.ClassDef) or not any(
145
+ imports.resolves(base, sources=_ENUM_SOURCES, symbol=symbol)
146
+ for base in node.bases
147
+ for symbol in ("Enum", "IntEnum", "StrEnum")
148
+ ):
149
+ continue
150
+ values: set[str] = set()
151
+ complete = True
152
+ for statement in node.body:
153
+ value = statement.value if isinstance(statement, (ast.Assign, ast.AnnAssign)) else None
154
+ if value is None:
155
+ continue
156
+ if not isinstance(value, ast.Constant) or not isinstance(value.value, str):
157
+ complete = False
158
+ break
159
+ values.add(value.value)
160
+ if complete and values:
161
+ domains[node.name] = frozenset(values)
162
+ return domains
163
+
164
+
165
+ def _closed_domain(
166
+ node: ast.expr, imports: ImportIndex, enum_domains: dict[str, frozenset[str]]
167
+ ) -> frozenset[str] | None:
168
+ if isinstance(node, ast.Name):
169
+ return enum_domains.get(node.id)
170
+ if not isinstance(node, ast.Subscript):
171
+ return None
172
+ if imports.resolves(node.value, sources=_TYPING_SOURCES, symbol="Literal"):
173
+ members = node.slice.elts if isinstance(node.slice, ast.Tuple) else (node.slice,)
174
+ values: set[str] = set()
175
+ for member in members:
176
+ if not isinstance(member, ast.Constant) or not isinstance(member.value, str):
177
+ return None
178
+ values.add(member.value)
179
+ return frozenset(values)
180
+ if imports.resolves(node.value, sources=_TYPING_SOURCES, symbol="Annotated"):
181
+ members = node.slice.elts if isinstance(node.slice, ast.Tuple) else (node.slice,)
182
+ return _closed_domain(members[0], imports, enum_domains) if members else None
183
+ return None
184
+
185
+
186
+ def _repeats_domain(description: str, domain: frozenset[str]) -> bool:
187
+ match = _DOMAIN_ONLY.fullmatch(description)
188
+ if match is None:
189
+ return False
190
+ body = match.group("body")
191
+ values = frozenset(item.group("value") for item in _QUOTED_VALUE.finditer(body))
192
+ if values != domain:
193
+ return False
194
+ residue = _QUOTED_VALUE.sub("", body)
195
+ residue = re.sub(r"\b(?:and|or)\b|[\s,\[\](){}]", "", residue, flags=re.IGNORECASE)
196
+ return not residue
197
+
198
+
199
+ def _field_calls(statement: ast.AnnAssign, imports: ImportIndex) -> tuple[ast.Call, ...]:
200
+ candidates: list[ast.expr] = []
201
+ if statement.value is not None:
202
+ candidates.append(statement.value)
203
+ annotation = statement.annotation
204
+ if isinstance(annotation, ast.Subscript) and imports.resolves(
205
+ annotation.value, sources=_TYPING_SOURCES, symbol="Annotated"
206
+ ):
207
+ members = annotation.slice.elts if isinstance(annotation.slice, ast.Tuple) else (annotation.slice,)
208
+ candidates.extend(members[1:])
209
+ return tuple(
210
+ candidate
211
+ for candidate in candidates
212
+ if isinstance(candidate, ast.Call)
213
+ and imports.resolves(candidate.func, sources=_PYDANTIC_FIELD_SOURCES, symbol="Field")
214
+ )