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.
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/.gitignore +1 -0
- sarj_python_lint-0.67.2/LICENSE +21 -0
- sarj_python_lint-0.67.2/PKG-INFO +36 -0
- sarj_python_lint-0.67.2/README.md +15 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/pyproject.toml +11 -4
- sarj_python_lint-0.67.2/src/sarj_python_lint/__init__.py +7 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/__main__.py +380 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/_filesystem.py +31 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/_ratchet_cli.py +81 -49
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/ratchet.py +51 -22
- sarj_python_lint-0.67.2/src/sarj_python_lint/rule_base.py +326 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_comments.py +84 -82
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_docstrings.py +1 -2
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_fastapi.py +342 -29
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_imports.py +97 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_logging.py +49 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_paths.py +161 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_prose_budget.py +341 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_registry.py +200 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_sql.py +36 -2
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/_test_assertions.py +65 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +270 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +54 -4
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +205 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/duplicate_test_body.py +308 -24
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/duplicated_override_docstring.py +69 -6
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +102 -42
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +333 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +365 -0
- {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
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/mock_without_spec.py +594 -0
- {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
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_comment_cruft.py +142 -33
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +148 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +191 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +123 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_file_level_suppression.py +129 -0
- {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
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +253 -0
- {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
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_generic_single_export_module.py +206 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +209 -106
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_isinstance_union_chain.py +311 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_long_comment.py +114 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_offset_pagination.py +123 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +206 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_query_with_many_joins.py +161 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_repeated_string_literal.py +252 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_restated_comment.py +155 -31
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_secret_in_log.py +177 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_select_star.py +62 -21
- {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
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_stdlib_logging.py +45 -6
- 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
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/no_tautological_expect.py +73 -23
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/no_typed_doc_sections.py +88 -0
- 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
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/over_mocked_test.py +83 -20
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/phase_label_comment.py +112 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_class_row.py +242 -0
- {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
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +243 -41
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +522 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_library_fake.py +134 -10
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_match_assert_never.py +90 -17
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +908 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_module_level_constant.py +78 -35
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +337 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_nominal_id_types.py +481 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +389 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_or_pattern.py +80 -10
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +505 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_self_type_annotation.py +260 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_str_enum.py +1416 -0
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +137 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +194 -29
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +74 -20
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +299 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +60 -6
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +132 -32
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/redundant_class_docstring.py +53 -5
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/redundant_docstring.py +55 -6
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/redundant_module_docstring.py +188 -0
- 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
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/require_port_for_service.py +650 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/restated_test_docstring.py +57 -7
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/stepdown.py +247 -61
- sarj_python_lint-0.67.2/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +155 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/trailing_value_narration.py +55 -7
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/trivially_true_assertion.py +190 -57
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/unused_mock_setup.py +147 -27
- sarj_python_lint-0.45.0/PKG-INFO +0 -474
- sarj_python_lint-0.45.0/README.md +0 -456
- sarj_python_lint-0.45.0/src/sarj_python_lint/__init__.py +0 -6
- sarj_python_lint-0.45.0/src/sarj_python_lint/__main__.py +0 -217
- sarj_python_lint-0.45.0/src/sarj_python_lint/rule_base.py +0 -105
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_logging.py +0 -48
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_paths.py +0 -93
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_prose_budget.py +0 -186
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/_registry.py +0 -207
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -920
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -113
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -138
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/interaction_only_test.py +0 -459
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/mock_without_spec.py +0 -328
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -66
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -213
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -79
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -92
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -176
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -174
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_long_comment.py +0 -31
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_offset_pagination.py +0 -73
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -123
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -77
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -97
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -148
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_secret_in_log.py +0 -88
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_sequential_await.py +0 -262
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -148
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -30
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -130
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/phase_label_comment.py +0 -52
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_class_row.py +0 -64
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -286
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -262
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -217
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -106
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -363
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -127
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -31
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_str_enum.py +0 -648
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -65
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -118
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/require_port_for_service.py +0 -389
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/single_public_export.py +0 -156
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -106
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -78
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -402
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -155
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -164
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/zero_assertion_test.py +0 -317
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.45.0 → sarj_python_lint-0.67.2}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
|
@@ -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.
|
|
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 =
|
|
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://
|
|
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,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)
|