sarj-python-lint 0.45.0__tar.gz → 0.67.2__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 (150) hide show
  1. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/.gitignore +1 -0
  2. sarj_python_lint-0.67.2/LICENSE +21 -0
  3. sarj_python_lint-0.67.2/PKG-INFO +36 -0
  4. sarj_python_lint-0.67.2/README.md +15 -0
  5. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/pyproject.toml +11 -4
  6. sarj_python_lint-0.67.2/src/sarj_python_lint/__init__.py +7 -0
  7. sarj_python_lint-0.67.2/src/sarj_python_lint/__main__.py +380 -0
  8. sarj_python_lint-0.67.2/src/sarj_python_lint/_filesystem.py +31 -0
  9. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/_ratchet_cli.py +81 -49
  10. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/ratchet.py +51 -22
  11. sarj_python_lint-0.67.2/src/sarj_python_lint/rule_base.py +326 -0
  12. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_comments.py +84 -82
  13. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_docstrings.py +1 -2
  14. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_fastapi.py +342 -29
  15. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_imports.py +97 -0
  16. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_logging.py +49 -0
  17. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_paths.py +161 -0
  18. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_prose_budget.py +341 -0
  19. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_registry.py +200 -0
  20. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_sql.py +36 -2
  21. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_test_assertions.py +65 -0
  22. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +270 -0
  23. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +54 -4
  24. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +205 -0
  25. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/duplicate_test_body.py +308 -24
  26. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/duplicated_override_docstring.py +69 -6
  27. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +102 -42
  28. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +333 -0
  29. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +365 -0
  30. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +60 -9
  31. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/mock_without_spec.py +594 -0
  32. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +75 -9
  33. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_comment_cruft.py +142 -33
  34. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +148 -0
  35. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +191 -0
  36. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +123 -0
  37. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_file_level_suppression.py +129 -0
  38. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_first_party_private_import.py +59 -6
  39. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +253 -0
  40. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +53 -6
  41. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_generic_single_export_module.py +206 -0
  42. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +209 -106
  43. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_isinstance_union_chain.py +311 -0
  44. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_long_comment.py +114 -0
  45. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_offset_pagination.py +123 -0
  46. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +206 -0
  47. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_query_with_many_joins.py +161 -0
  48. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_repeated_string_literal.py +252 -0
  49. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_restated_comment.py +155 -31
  50. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_secret_in_log.py +177 -0
  51. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_select_star.py +62 -21
  52. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +126 -50
  53. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_stdlib_logging.py +45 -6
  54. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py → sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_string_concat_in_loop.py +225 -29
  55. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_tautological_expect.py +73 -23
  56. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_typed_doc_sections.py +88 -0
  57. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/parametrize_case_needs_id.py → sarj_python_lint-0.67.2/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +96 -20
  58. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/over_mocked_test.py +83 -20
  59. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/phase_label_comment.py +112 -0
  60. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_class_row.py +242 -0
  61. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +151 -14
  62. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +243 -41
  63. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +522 -0
  64. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_library_fake.py +134 -10
  65. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +90 -17
  66. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +908 -0
  67. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +78 -35
  68. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +337 -0
  69. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_nominal_id_types.py +481 -0
  70. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +389 -0
  71. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +80 -10
  72. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +505 -0
  73. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_self_type_annotation.py +260 -0
  74. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_str_enum.py +1416 -0
  75. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +137 -0
  76. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +194 -29
  77. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +74 -20
  78. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +299 -0
  79. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +60 -6
  80. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +132 -32
  81. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +53 -5
  82. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/redundant_docstring.py +55 -6
  83. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/redundant_module_docstring.py +188 -0
  84. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/kwonly_same_type_params.py → sarj_python_lint-0.67.2/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +85 -16
  85. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/require_port_for_service.py +650 -0
  86. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/restated_test_docstring.py +57 -7
  87. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/stepdown.py +247 -61
  88. sarj_python_lint-0.67.2/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +155 -0
  89. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/trailing_value_narration.py +55 -7
  90. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/trivially_true_assertion.py +190 -57
  91. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/unused_mock_setup.py +147 -27
  92. sarj_python_lint-0.45.0/PKG-INFO +0 -474
  93. sarj_python_lint-0.45.0/README.md +0 -456
  94. sarj_python_lint-0.45.0/src/sarj_python_lint/__init__.py +0 -6
  95. sarj_python_lint-0.45.0/src/sarj_python_lint/__main__.py +0 -217
  96. sarj_python_lint-0.45.0/src/sarj_python_lint/rule_base.py +0 -105
  97. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_logging.py +0 -48
  98. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_paths.py +0 -93
  99. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_prose_budget.py +0 -186
  100. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_registry.py +0 -207
  101. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -920
  102. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -113
  103. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -138
  104. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/interaction_only_test.py +0 -459
  105. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/mock_without_spec.py +0 -328
  106. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -66
  107. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -213
  108. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -79
  109. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -92
  110. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -176
  111. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -174
  112. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_long_comment.py +0 -31
  113. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_offset_pagination.py +0 -73
  114. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -123
  115. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -77
  116. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -97
  117. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -148
  118. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_secret_in_log.py +0 -88
  119. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_sequential_await.py +0 -262
  120. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -148
  121. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -30
  122. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -130
  123. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/phase_label_comment.py +0 -52
  124. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_class_row.py +0 -64
  125. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -286
  126. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -262
  127. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -217
  128. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -106
  129. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -363
  130. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -127
  131. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -31
  132. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_str_enum.py +0 -648
  133. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -65
  134. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -118
  135. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/require_port_for_service.py +0 -389
  136. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/single_public_export.py +0 -156
  137. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -106
  138. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -78
  139. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -402
  140. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -155
  141. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -164
  142. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/zero_assertion_test.py +0 -317
  143. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/_secret_names.py +0 -0
  144. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/_version.py +0 -0
  145. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/py.typed +0 -0
  146. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/__init__.py +0 -0
  147. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  148. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_first_party.py +0 -0
  149. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_pytest.py +0 -0
  150. {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
@@ -1,4 +1,5 @@
1
1
  node_modules/
2
+ .astro/
2
3
  dist/
3
4
  build/
4
5
  *.tgz
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sarj-ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.4
2
+ Name: sarj-python-lint
3
+ Version: 0.67.2
4
+ Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
+ Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
+ Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
7
+ Project-URL: Changelog, https://github.com/sarj-ai/standards/releases
8
+ Project-URL: Repository, https://github.com/sarj-ai/standards
9
+ Project-URL: Issues, https://github.com/sarj-ai/standards/issues
10
+ Author: sarj-ai
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.14
20
+ Description-Content-Type: text/markdown
21
+
22
+ <!-- Generated by `sarj-standards maintain docs sync`; do not edit. -->
23
+
24
+ # sarj-python-lint
25
+
26
+ Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
27
+
28
+ ```bash
29
+ uv tool install sarj-python-lint
30
+ ```
31
+
32
+ ```bash
33
+ sarj-python-lint --help
34
+ ```
35
+
36
+ [Documentation](https://code-standards.sarj.ai/rules/python/) · [Source](https://github.com/sarj-ai/standards)
@@ -0,0 +1,15 @@
1
+ <!-- Generated by `sarj-standards maintain docs sync`; do not edit. -->
2
+
3
+ # sarj-python-lint
4
+
5
+ Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
6
+
7
+ ```bash
8
+ uv tool install sarj-python-lint
9
+ ```
10
+
11
+ ```bash
12
+ sarj-python-lint --help
13
+ ```
14
+
15
+ [Documentation](https://code-standards.sarj.ai/rules/python/) · [Source](https://github.com/sarj-ai/standards)
@@ -1,10 +1,11 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.45.0"
3
+ version = "0.67.2"
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" }]
7
- license = { text = "MIT" }
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
8
9
  requires-python = ">=3.14"
9
10
  classifiers = [
10
11
  "Development Status :: 4 - Beta",
@@ -21,7 +22,9 @@ sarj-python-lint = "sarj_python_lint.__main__:main"
21
22
  sarj-ratchet = "sarj_python_lint._ratchet_cli:main"
22
23
 
23
24
  [project.urls]
24
- Homepage = "https://github.com/sarj-ai/standards/tree/main/packages/python"
25
+ Homepage = "https://code-standards.sarj.ai/rules/python/"
26
+ Documentation = "https://code-standards.sarj.ai/rules/python/"
27
+ Changelog = "https://github.com/sarj-ai/standards/releases"
25
28
  Repository = "https://github.com/sarj-ai/standards"
26
29
  Issues = "https://github.com/sarj-ai/standards/issues"
27
30
 
@@ -33,7 +36,7 @@ dev = [
33
36
  ]
34
37
 
35
38
  [build-system]
36
- requires = ["hatchling"]
39
+ requires = ["hatchling==1.31.0"]
37
40
  build-backend = "hatchling.build"
38
41
 
39
42
  [tool.hatch.build.targets.wheel]
@@ -43,6 +46,7 @@ packages = ["src/sarj_python_lint"]
43
46
  include = [
44
47
  "src",
45
48
  "README.md",
49
+ "LICENSE",
46
50
  "pyproject.toml",
47
51
  ]
48
52
  exclude = [
@@ -53,6 +57,9 @@ exclude = [
53
57
 
54
58
  [tool.pytest.ini_options]
55
59
  testpaths = ["tests"]
60
+ markers = [
61
+ "illustrative: executes reviewed examples published in the generated rule catalog",
62
+ ]
56
63
 
57
64
  # Dogfooding: linted/formatted by this repo's own published config (root-synced).
58
65
  [tool.ruff]
@@ -0,0 +1,7 @@
1
+ """sarj-python-lint — custom Python lint rules."""
2
+
3
+ from sarj_python_lint._ratchet_cli import main as run_ratchet
4
+ from sarj_python_lint._version import __version__
5
+
6
+
7
+ __all__ = ["__version__", "run_ratchet"]
@@ -0,0 +1,380 @@
1
+ """CLI: sarj-python-lint check --rule <id> [--rule <id2>] [--baseline <json>] <files>."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ from collections import Counter
8
+ import json
9
+ from pathlib import Path
10
+ import sys
11
+ from types import MappingProxyType
12
+
13
+ from sarj_python_lint import __version__
14
+ from sarj_python_lint._filesystem import atomic_write_text
15
+ from sarj_python_lint.rule_base import Diagnostic, Severity, is_suppressed
16
+ from sarj_python_lint.rules import REGISTRY
17
+ from sarj_python_lint.rules._paths import clear_path_caches
18
+
19
+
20
+ SKIP_DIR_NAMES = frozenset(
21
+ {
22
+ "node_modules",
23
+ ".venv",
24
+ "venv",
25
+ ".git",
26
+ "dist",
27
+ "build",
28
+ ".next",
29
+ "coverage",
30
+ "__pycache__",
31
+ ".pytest_cache",
32
+ ".ruff_cache",
33
+ ".uv-cache",
34
+ ".mypy_cache",
35
+ ".turbo",
36
+ ".yarn",
37
+ ".pnpm-store",
38
+ }
39
+ )
40
+
41
+ # Skip files larger than this — they are almost always generated/vendored, not
42
+ # hand-written source worth linting.
43
+ _MAX_FILE_BYTES = 500_000
44
+
45
+
46
+ def _expand_paths(paths: list[Path]) -> list[Path]:
47
+ out: list[Path] = []
48
+ for p in paths:
49
+ if not p.exists():
50
+ msg = f"input does not exist: {p}"
51
+ raise ValueError(msg)
52
+ if p.is_file():
53
+ try:
54
+ if p.stat().st_size <= _MAX_FILE_BYTES:
55
+ out.append(p)
56
+ except OSError:
57
+ pass
58
+ continue
59
+ for child in p.rglob("*.py"):
60
+ if not child.is_file():
61
+ continue
62
+ if any(part in SKIP_DIR_NAMES for part in child.parts):
63
+ continue
64
+ try:
65
+ if child.stat().st_size > _MAX_FILE_BYTES:
66
+ continue
67
+ except OSError:
68
+ continue
69
+ out.append(child)
70
+ return out
71
+
72
+
73
+ def _check(rule_ids: list[str], paths: list[Path]) -> list[Diagnostic]:
74
+ unknown = [rid for rid in rule_ids if rid not in REGISTRY]
75
+ if unknown:
76
+ sys.stderr.write(f"unknown rule(s): {', '.join(unknown)}\n")
77
+ sys.stderr.write(f"available: {', '.join(sorted(REGISTRY))}\n")
78
+ raise SystemExit(2)
79
+ rules = [REGISTRY[rid]() for rid in rule_ids]
80
+ clear_path_caches()
81
+ expanded = _expand_paths(paths)
82
+ diags: list[Diagnostic] = []
83
+ for p in expanded:
84
+ try:
85
+ source = p.read_text(encoding="utf-8", errors="replace")
86
+ except OSError:
87
+ continue
88
+ source_lines = source.splitlines()
89
+ raw = [diagnostic for rule in rules for diagnostic in rule.check(p, source)]
90
+ diags.extend(
91
+ diagnostic
92
+ for diagnostic in deduplicate_diagnostics(
93
+ [diagnostic for diagnostic in raw if not is_suppressed(source_lines, diagnostic.line, diagnostic.code)],
94
+ source=source,
95
+ )
96
+ )
97
+ return diags
98
+
99
+
100
+ def analyze(
101
+ rule_ids: list[str],
102
+ paths: list[Path],
103
+ *,
104
+ baseline: Path | None = None,
105
+ root: Path | None = None,
106
+ ) -> list[Diagnostic]:
107
+ """Return native diagnostics without rendering CLI output."""
108
+ diagnostics = _check(rule_ids, paths)
109
+ return diagnostics if baseline is None else _apply_baseline(diagnostics, _read_baseline(baseline), root=root)
110
+
111
+
112
+ _DIAGNOSTIC_PRECEDENCE = MappingProxyType(
113
+ {
114
+ "SARJ003": frozenset({"SARJ080"}),
115
+ "SARJ084": frozenset({"SARJ050", "SARJ091"}),
116
+ "SARJ088": frozenset({"SARJ050", "SARJ085", "SARJ091"}),
117
+ "SARJ092": frozenset({"SARJ086", "SARJ087"}),
118
+ "SARJ093": frozenset({"SARJ034"}),
119
+ }
120
+ )
121
+
122
+
123
+ def deduplicate_diagnostics(diags: list[Diagnostic], *, source: str | None = None) -> list[Diagnostic]:
124
+ """Keep the most specific remediation at a source location."""
125
+ codes = frozenset(diagnostic.code for diagnostic in diags)
126
+ needs_docstring_owners = "SARJ092" in codes and not codes.isdisjoint(_DIAGNOSTIC_PRECEDENCE["SARJ092"])
127
+ docstring_owners = _docstring_owner_locations(source) if source is not None and needs_docstring_owners else {}
128
+ needs_signature_owners = "SARJ093" in codes and "SARJ034" in codes
129
+ signature_owners = (
130
+ _function_signature_owner_locations(source) if source is not None and needs_signature_owners else {}
131
+ )
132
+
133
+ def owner_location(diagnostic: Diagnostic) -> tuple[int, int]:
134
+ if diagnostic.code in {"SARJ034", "SARJ093"}:
135
+ return signature_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
136
+ return docstring_owners.get(diagnostic.line, (diagnostic.line, diagnostic.col))
137
+
138
+ present: dict[tuple[Path, int, int], dict[str, set[Severity]]] = {}
139
+ for diagnostic in diags:
140
+ line, col = owner_location(diagnostic)
141
+ by_code = present.setdefault((diagnostic.path, line, col), {})
142
+ by_code.setdefault(diagnostic.code, set()).add(diagnostic.severity)
143
+ suppressed = {
144
+ (location, generic, generic_severity)
145
+ for location, codes in present.items()
146
+ for specific, generics in _DIAGNOSTIC_PRECEDENCE.items()
147
+ if specific in codes
148
+ for generic in generics
149
+ for generic_severity in codes.get(generic, set())
150
+ if generic_severity is Severity.WARNING or Severity.ERROR in codes[specific]
151
+ }
152
+ return [
153
+ diagnostic
154
+ for diagnostic in diags
155
+ if (
156
+ (
157
+ (
158
+ diagnostic.path,
159
+ *owner_location(diagnostic),
160
+ ),
161
+ diagnostic.code,
162
+ diagnostic.severity,
163
+ )
164
+ not in suppressed
165
+ )
166
+ ]
167
+
168
+
169
+ def _function_signature_owner_locations(source: str) -> dict[int, tuple[int, int]]:
170
+ """Map signature lines to their function opening for cross-rule precedence."""
171
+ try:
172
+ tree = ast.parse(source)
173
+ except SyntaxError:
174
+ return {}
175
+ owners: dict[int, tuple[int, int]] = {}
176
+ for node in ast.walk(tree):
177
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
178
+ continue
179
+ body_line = node.body[0].lineno if node.body else node.lineno + 1
180
+ end_line = max(node.lineno, body_line - 1)
181
+ owners.update(dict.fromkeys(range(node.lineno, end_line + 1), (node.lineno, node.col_offset + 1)))
182
+ return owners
183
+
184
+
185
+ def _docstring_owner_locations(source: str) -> dict[int, tuple[int, int]]:
186
+ """Map every physical docstring line to the opening expression that owns it."""
187
+ try:
188
+ tree = ast.parse(source)
189
+ except SyntaxError:
190
+ tree = None
191
+ if tree is None:
192
+ return {}
193
+ owners: dict[int, tuple[int, int]] = {}
194
+ for node in ast.walk(tree):
195
+ if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) or not node.body:
196
+ continue
197
+ expression = node.body[0]
198
+ if not (
199
+ isinstance(expression, ast.Expr)
200
+ and isinstance(expression.value, ast.Constant)
201
+ and isinstance(expression.value.value, str)
202
+ ):
203
+ continue
204
+ location = (expression.lineno, expression.col_offset + 1)
205
+ lines = range(expression.lineno, (expression.end_lineno or expression.lineno) + 1)
206
+ owners.update(dict.fromkeys(lines, location))
207
+ return owners
208
+
209
+
210
+ class _Args(argparse.Namespace):
211
+ cmd: str | None
212
+ rule: list[str]
213
+ # `explain` takes exactly one rule, so it cannot share `--rule`'s list slot
214
+ # without widening the type and losing the check on every `check` call site.
215
+ which: str
216
+ files: list[Path]
217
+ baseline: Path | None
218
+ update_baseline: Path | None
219
+
220
+ def __init__(self) -> None:
221
+ super().__init__()
222
+ self.cmd = None
223
+ self.rule = []
224
+ self.which = ""
225
+ self.files = []
226
+ self.baseline = None
227
+ self.update_baseline = None
228
+
229
+
230
+ def _explain(wanted: str) -> int:
231
+ """Print a rule's description and its derived examples link."""
232
+ key = wanted.strip()
233
+ cls = REGISTRY.get(key) or next((c for c in REGISTRY.values() if c.code.upper() == key.upper()), None)
234
+ if cls is None:
235
+ sys.stderr.write(f"unknown rule: {wanted}\navailable: {', '.join(sorted(REGISTRY))}\n")
236
+ return 2
237
+ sys.stdout.write(f"{cls.code} {cls.id}\n{cls.description}\nexamples: {cls.examples_url()}\n")
238
+ return 0
239
+
240
+
241
+ def _baseline_counts(diags: list[Diagnostic]) -> dict[str, dict[str, int]]:
242
+ counts: dict[str, dict[str, int]] = {}
243
+ for d in diags:
244
+ if d.severity is Severity.WARNING:
245
+ continue
246
+ key = _baseline_path(d.path)
247
+ counts.setdefault(key, {})
248
+ counts[key][d.code] = counts[key].get(d.code, 0) + 1
249
+ return counts
250
+
251
+
252
+ def _baseline_path(path: Path, *, root: Path | None = None) -> str:
253
+ """Make baselines portable when a caller supplies repository-absolute paths."""
254
+ try:
255
+ return path.resolve().relative_to((Path.cwd() if root is None else root).resolve()).as_posix()
256
+ except ValueError:
257
+ return str(path)
258
+
259
+
260
+ def _read_baseline(path: Path) -> dict[str, dict[str, int]]:
261
+ """Load a baseline file, keeping only well-formed `{path: {CODE: count}}` entries."""
262
+ raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; the shape is narrowed below
263
+ path.read_text(encoding="utf-8")
264
+ )
265
+ if not isinstance(raw, dict):
266
+ return {}
267
+ counts: dict[str, dict[str, int]] = {}
268
+ for file_key, per_code in raw.items(): # pyright: ignore[reportUnknownVariableType] — json.loads yields Any leaves
269
+ if not isinstance(file_key, str) or not isinstance(per_code, dict):
270
+ continue
271
+ counts[file_key] = {
272
+ code: n
273
+ for code, n in per_code.items() # pyright: ignore[reportUnknownVariableType] — same
274
+ if isinstance(code, str) and isinstance(n, int)
275
+ }
276
+ return counts
277
+
278
+
279
+ def _apply_baseline(
280
+ diags: list[Diagnostic],
281
+ baseline: dict[str, dict[str, int]],
282
+ *,
283
+ root: Path | None = None,
284
+ ) -> list[Diagnostic]:
285
+ """Suppress up to the baselined count per (path, code); excess diags survive."""
286
+ seen: Counter[tuple[str, str]] = Counter()
287
+ out: list[Diagnostic] = []
288
+ for d in diags:
289
+ if d.severity is Severity.WARNING:
290
+ out.append(d)
291
+ continue
292
+ path_key = _baseline_path(d.path, root=root)
293
+ key = (path_key, d.code)
294
+ seen[key] += 1
295
+ # The raw lookup preserves compatibility with baselines written before
296
+ # repository-relative keys were introduced.
297
+ allowance = max(
298
+ baseline.get(path_key, {}).get(d.code, 0),
299
+ baseline.get(str(d.path), {}).get(d.code, 0),
300
+ )
301
+ if seen[key] > allowance:
302
+ out.append(d)
303
+ return out
304
+
305
+
306
+ def main(argv: list[str] | None = None) -> int:
307
+ parser = argparse.ArgumentParser(
308
+ prog="sarj-python-lint",
309
+ description="Custom Python lint rules.",
310
+ )
311
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
312
+ sub = parser.add_subparsers(dest="cmd", required=True)
313
+
314
+ check_p = sub.add_parser("check", help="Run rules over files.")
315
+ check_p.add_argument(
316
+ "--rule",
317
+ action="append",
318
+ required=True,
319
+ help="Rule ID (repeat for multiple).",
320
+ )
321
+ check_p.add_argument(
322
+ "--baseline",
323
+ type=Path,
324
+ help="Per-file shrink-only baseline JSON: {path: {CODE: count}}. Diags up to the baselined count are suppressed.",
325
+ )
326
+ check_p.add_argument(
327
+ "--update-baseline",
328
+ type=Path,
329
+ help="Write the current per-file diagnostic counts to this JSON and exit 0.",
330
+ )
331
+ check_p.add_argument("files", nargs="+", type=Path)
332
+
333
+ sub.add_parser("list-rules", help="List available rule IDs.")
334
+
335
+ explain_p = sub.add_parser("explain", help="Print one rule's summary and the links to its examples and evidence.")
336
+ explain_p.add_argument("which", metavar="rule", help="Rule ID or SARJ code.")
337
+
338
+ args = parser.parse_args(argv, namespace=_Args())
339
+
340
+ if args.cmd == "list-rules":
341
+ for rid, cls in sorted(REGISTRY.items()):
342
+ inst = cls()
343
+ sys.stdout.write(f"{inst.code:8} {rid:40} {inst.description}\n")
344
+ return 0
345
+
346
+ if args.cmd == "explain":
347
+ return _explain(args.which)
348
+
349
+ try:
350
+ diags = analyze(args.rule, args.files)
351
+ except (OSError, ValueError) as exc:
352
+ sys.stderr.write(f"error: {exc}\n")
353
+ return 2
354
+ if args.update_baseline is not None:
355
+ counts = _baseline_counts(diags)
356
+ blocking = sum(d.severity is Severity.ERROR for d in diags)
357
+ warnings = len(diags) - blocking
358
+ try:
359
+ atomic_write_text(args.update_baseline, json.dumps(counts, indent=2, sort_keys=True) + "\n")
360
+ except OSError as exc:
361
+ sys.stderr.write(f"error: cannot write baseline {args.update_baseline}: {exc}\n")
362
+ return 2
363
+ sys.stdout.write(
364
+ f"baseline written: {args.update_baseline} "
365
+ f"({blocking} blocking diagnostics over {len(counts)} files; {warnings} warnings excluded)\n"
366
+ )
367
+ return 0
368
+ if args.baseline is not None:
369
+ try:
370
+ diags = _apply_baseline(diags, _read_baseline(args.baseline))
371
+ except (OSError, ValueError) as exc:
372
+ sys.stderr.write(f"error: invalid baseline {args.baseline}: {exc}\n")
373
+ return 2
374
+ for d in diags:
375
+ sys.stdout.write(d.format() + "\n")
376
+ return 1 if any(d.severity is Severity.ERROR for d in diags) else 0
377
+
378
+
379
+ if __name__ == "__main__":
380
+ sys.exit(main())
@@ -0,0 +1,31 @@
1
+ """Small safe filesystem primitives shared by command-line workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import tempfile
8
+
9
+
10
+ def atomic_write_text(path: Path, contents: str) -> None:
11
+ """Replace an existing-parent destination atomically."""
12
+ if not path.parent.is_dir():
13
+ msg = f"baseline parent does not exist: {path.parent}"
14
+ raise OSError(msg)
15
+ temporary: Path | None = None
16
+ try:
17
+ with tempfile.NamedTemporaryFile(
18
+ "w",
19
+ encoding="utf-8",
20
+ dir=path.parent,
21
+ prefix=f".{path.name}.",
22
+ delete=False,
23
+ ) as handle:
24
+ temporary = Path(handle.name)
25
+ handle.write(contents)
26
+ handle.flush()
27
+ os.fsync(handle.fileno())
28
+ Path(temporary).replace(path)
29
+ finally:
30
+ if temporary is not None:
31
+ temporary.unlink(missing_ok=True)