sarj-python-lint 0.79.0__tar.gz → 0.80.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.
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/PKG-INFO +2 -1
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/pyproject.toml +2 -2
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_ratchet_cli.py +50 -2
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/ratchet.py +95 -22
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_registry.py +2 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_raw_connection_in_tests.py +47 -3
- sarj_python_lint-0.80.0/src/sarj_python_lint/rules/prefer_walrus_awaited_none_guard.py +294 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/.gitignore +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/LICENSE +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/README.md +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_analysis_session.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_filesystem.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_imports.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_project_index.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/defect_xfail_requires_explicit_strict.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/fakes_in_shared_location.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/fastapi_explicit_openapi_contract.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/iac_source_coupled_test.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/negative_only_http_status_assertion.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_analytical_aggregation_in_postgres_store.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_conftest_test_module_import.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_copied_inherited_docstring.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_fastapi_on_event.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_suppression.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_nested_pydantic_field_validator.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_redundant_literal_description.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_repeated_test_body.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_unique_violation_message_match.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_unnecessary_docstring.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_vague_suppression_description.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_collection_comprehension.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_one_for_required_row.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_set_isdisjoint.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/preserve_declared_nominal_id.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/preserve_enum_types.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/production_derived_test_cases.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/pytest_fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/repeated_kwarg_heavy_call_in_test.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/repeated_static_call_cases.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_nodecode_for_splitting_settings_field.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_pydantic_for_external_json.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_pydantic_ordinal_lower_bound.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/require_validated_row_factory.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/source_coupled_test.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/sql_requires_injected_pool_owner.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/store_get_delegates_to_bulk_read.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/timestamp_order_requires_tiebreaker.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/uncontrolled_randomness_in_test.py +0 -0
- {sarj_python_lint-0.79.0 → sarj_python_lint-0.80.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.
|
|
3
|
+
Version: 0.80.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/
|
|
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Classifier: Topic :: Software Development :: Quality Assurance
|
|
19
19
|
Requires-Python: >=3.14
|
|
20
|
+
Requires-Dist: ruff>=0.16.3
|
|
20
21
|
Description-Content-Type: text/markdown
|
|
21
22
|
|
|
22
23
|
<!-- Generated by `code-standards maintain docs sync`; do not edit. -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sarj-python-lint"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.80.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" }]
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
"Programming Language :: Python :: 3.14",
|
|
16
16
|
"Topic :: Software Development :: Quality Assurance",
|
|
17
17
|
]
|
|
18
|
-
dependencies = []
|
|
18
|
+
dependencies = ["ruff>=0.16.3"]
|
|
19
19
|
|
|
20
20
|
[project.scripts]
|
|
21
21
|
sarj-python-lint = "sarj_python_lint.__main__:main"
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import json
|
|
4
5
|
from pathlib import Path
|
|
6
|
+
import subprocess # ruff: ignore[suspicious-subprocess-import] -- fixed local Ruff introspection only
|
|
5
7
|
import sys
|
|
6
|
-
from typing import TYPE_CHECKING
|
|
8
|
+
from typing import TYPE_CHECKING, TypeGuard
|
|
7
9
|
|
|
8
10
|
from sarj_python_lint._filesystem import atomic_write_text
|
|
9
11
|
from sarj_python_lint._version import __version__
|
|
@@ -93,7 +95,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
93
95
|
file_exceptions=baseline.file_exceptions,
|
|
94
96
|
excluded_subtrees=excluded_subtrees,
|
|
95
97
|
)
|
|
96
|
-
|
|
98
|
+
try:
|
|
99
|
+
ruff_aliases = _ruff_selector_aliases()
|
|
100
|
+
measurement = measure(
|
|
101
|
+
root,
|
|
102
|
+
packages,
|
|
103
|
+
excluded_subtrees=excluded_subtrees,
|
|
104
|
+
ruff_aliases=ruff_aliases,
|
|
105
|
+
)
|
|
106
|
+
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
107
|
+
sys.stderr.write(f"sarj-ratchet: cannot measure suppressions: {exc}\n")
|
|
108
|
+
return 2
|
|
97
109
|
|
|
98
110
|
if args.update:
|
|
99
111
|
# A first seed has nothing to raise: every ceiling is 0 only because no
|
|
@@ -124,6 +136,42 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
124
136
|
return 0
|
|
125
137
|
|
|
126
138
|
|
|
139
|
+
def _ruff_selector_aliases() -> dict[str, str]:
|
|
140
|
+
completed = subprocess.run(
|
|
141
|
+
[sys.executable, "-m", "ruff", "rule", "--all", "--output-format", "json"],
|
|
142
|
+
check=True,
|
|
143
|
+
capture_output=True,
|
|
144
|
+
text=True,
|
|
145
|
+
)
|
|
146
|
+
raw: object = json.loads(completed.stdout) # pyright: ignore[reportAny] -- validated below
|
|
147
|
+
if not _is_object_list(raw):
|
|
148
|
+
msg = "Ruff rule catalog was not a JSON list"
|
|
149
|
+
raise TypeError(msg)
|
|
150
|
+
aliases: dict[str, str] = {}
|
|
151
|
+
for raw_item in raw:
|
|
152
|
+
if not _is_string_object_mapping(raw_item):
|
|
153
|
+
msg = "Ruff rule catalog contains a non-object entry"
|
|
154
|
+
raise TypeError(msg)
|
|
155
|
+
code = raw_item.get("code")
|
|
156
|
+
name = raw_item.get("name")
|
|
157
|
+
if not isinstance(code, str) or not isinstance(name, str):
|
|
158
|
+
msg = "Ruff rule catalog entry lacks a string code or name"
|
|
159
|
+
raise TypeError(msg)
|
|
160
|
+
canonical = code.upper()
|
|
161
|
+
aliases[canonical] = canonical
|
|
162
|
+
aliases[name.lower()] = canonical
|
|
163
|
+
return aliases
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_object_list(value: object) -> TypeGuard[list[object]]:
|
|
167
|
+
return isinstance(value, list)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _is_string_object_mapping(value: object) -> TypeGuard[dict[str, object]]:
|
|
171
|
+
# JSON object keys are strings by definition; json.loads supplied `value`.
|
|
172
|
+
return isinstance(value, dict)
|
|
173
|
+
|
|
174
|
+
|
|
127
175
|
def _build_parser() -> argparse.ArgumentParser:
|
|
128
176
|
parser = argparse.ArgumentParser(
|
|
129
177
|
prog="sarj-ratchet",
|
|
@@ -2,8 +2,10 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from collections import Counter
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
|
+
from io import StringIO
|
|
5
6
|
import json
|
|
6
7
|
import re
|
|
8
|
+
import tokenize
|
|
7
9
|
from types import MappingProxyType
|
|
8
10
|
from typing import TYPE_CHECKING, Final, NamedTuple, TypeGuard
|
|
9
11
|
|
|
@@ -37,18 +39,28 @@ class Improvement(NamedTuple):
|
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
DEFAULT_PER_FILE_CEILING: Final = 10
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
BASELINE_SCHEMA_VERSION: Final = 1
|
|
43
|
+
|
|
44
|
+
_RUFF_SELECTOR: Final = r"[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*"
|
|
45
|
+
_RUFF_SELECTOR_LIST: Final = rf"({_RUFF_SELECTOR}(?:\s*,\s*{_RUFF_SELECTOR})*)"
|
|
46
|
+
_NOQA_RE: Final = re.compile(rf"#\s*noqa(?::\s*{_RUFF_SELECTOR_LIST})?", re.IGNORECASE)
|
|
47
|
+
_RUFF_IGNORE_RE: Final = re.compile(rf"#\s*ruff:\s*ignore\s*\[{_RUFF_SELECTOR_LIST}(?:\s*,)?\s*\]", re.IGNORECASE)
|
|
48
|
+
_RUFF_FILE_IGNORE_RE: Final = re.compile(
|
|
49
|
+
rf"^#\s*ruff:\s*file-ignore\s*\[{_RUFF_SELECTOR_LIST}(?:\s*,)?\s*\]", re.IGNORECASE
|
|
50
|
+
)
|
|
51
|
+
_RUFF_DISABLE_RE: Final = re.compile(rf"^#\s*ruff:\s*disable\s*\[{_RUFF_SELECTOR_LIST}(?:\s*,)?\s*\]", re.IGNORECASE)
|
|
42
52
|
_SARJ_NOQA_RE: Final = re.compile(r"#\s*sarj-noqa:\s*(SARJ\d+(?:\s*,\s*SARJ\d+)*)", re.IGNORECASE)
|
|
43
53
|
_PYRIGHT_IGNORE_RE: Final = re.compile(r"#\s*pyright:\s*ignore\[([^\]]+)\]")
|
|
44
54
|
_TYPE_IGNORE_RE: Final = re.compile(r"#\s*type:\s*ignore(?:\[([^\]]+)\])?")
|
|
45
|
-
_FILE_NOQA_RE: Final = re.compile(
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
_FILE_PYRIGHT_RE: Final = re.compile(r"^\s*#\s*pyright:\s*(?!ignore\b)")
|
|
55
|
+
_FILE_NOQA_RE: Final = re.compile(rf"^#\s*ruff:\s*noqa(?::\s*{_RUFF_SELECTOR_LIST})?", re.IGNORECASE)
|
|
56
|
+
_FLAKE8_FILE_NOQA_RE: Final = re.compile(r"^#\s*flake8:\s*noqa\b", re.IGNORECASE)
|
|
57
|
+
_FILE_PYRIGHT_RE: Final = re.compile(r"^#\s*pyright:(?!\s*ignore\b)\s*")
|
|
49
58
|
_FILE_PYRIGHT_RULE_RE: Final = re.compile(r"([A-Za-z]\w*)\s*=\s*false")
|
|
50
59
|
|
|
51
60
|
_BLANKET_KEY: Final = "file-noqa:<blanket>"
|
|
61
|
+
_INLINE_BLANKET_KEY: Final = "noqa:<blanket>"
|
|
62
|
+
_FLAKE8_BLANKET_KEY: Final = "file-noqa:<blanket>"
|
|
63
|
+
_BARE_PYRIGHT_IGNORE_KEY: Final = "pyright:<blanket>"
|
|
52
64
|
_BARE_TYPE_IGNORE_KEY: Final = "type-ignore"
|
|
53
65
|
|
|
54
66
|
|
|
@@ -111,6 +123,7 @@ def measure(
|
|
|
111
123
|
*,
|
|
112
124
|
excluded_dir_names: frozenset[str] = DEFAULT_EXCLUDED_DIR_NAMES,
|
|
113
125
|
excluded_subtrees: Iterable[str] = (),
|
|
126
|
+
ruff_aliases: Mapping[str, str] | None = None,
|
|
114
127
|
) -> Measurement:
|
|
115
128
|
codes: Counter[str] = Counter()
|
|
116
129
|
package_counts: Counter[str] = Counter()
|
|
@@ -122,7 +135,7 @@ def measure(
|
|
|
122
135
|
relative = path.relative_to(root).as_posix()
|
|
123
136
|
if any(_inside_subtree(relative, subtree) for subtree in excluded):
|
|
124
137
|
continue
|
|
125
|
-
found = count_source(_read(path))
|
|
138
|
+
found = count_source(_read(path), ruff_aliases=ruff_aliases)
|
|
126
139
|
if not found:
|
|
127
140
|
continue
|
|
128
141
|
codes.update(found)
|
|
@@ -132,10 +145,15 @@ def measure(
|
|
|
132
145
|
return Measurement(codes=codes, packages=package_counts, files=files)
|
|
133
146
|
|
|
134
147
|
|
|
135
|
-
def count_source(source: str) -> Counter[str]:
|
|
148
|
+
def count_source(source: str, *, ruff_aliases: Mapping[str, str] | None = None) -> Counter[str]:
|
|
136
149
|
counts: Counter[str] = Counter()
|
|
137
|
-
|
|
138
|
-
|
|
150
|
+
lines = source.splitlines()
|
|
151
|
+
for token in tokenize.generate_tokens(StringIO(source).readline):
|
|
152
|
+
if token.type != tokenize.COMMENT:
|
|
153
|
+
continue
|
|
154
|
+
line = lines[token.start[0] - 1] if token.start[0] <= len(lines) else ""
|
|
155
|
+
standalone = not line[: token.start[1]].strip()
|
|
156
|
+
_count_comment(token.string, counts, standalone=standalone, ruff_aliases=ruff_aliases)
|
|
139
157
|
return counts
|
|
140
158
|
|
|
141
159
|
|
|
@@ -183,6 +201,10 @@ def load_baseline(path: Path) -> Baseline:
|
|
|
183
201
|
raw: object = json.loads( # pyright: ignore[reportAny] — json.loads is an untyped stdlib boundary; every read below narrows
|
|
184
202
|
path.read_text(encoding="utf-8")
|
|
185
203
|
)
|
|
204
|
+
schema = _get(raw, "schema_version")
|
|
205
|
+
if schema is not None and schema != BASELINE_SCHEMA_VERSION:
|
|
206
|
+
msg = f"unsupported suppression baseline schema_version: {schema!r}"
|
|
207
|
+
raise ValueError(msg)
|
|
186
208
|
files = _get(raw, "files")
|
|
187
209
|
ceiling = _get(files, "per_file_ceiling")
|
|
188
210
|
return Baseline(
|
|
@@ -204,6 +226,7 @@ def _get(mapping: object, key: str) -> object:
|
|
|
204
226
|
|
|
205
227
|
def dump_baseline(baseline: Baseline, packages: Iterable[str]) -> str:
|
|
206
228
|
payload = {
|
|
229
|
+
"schema_version": BASELINE_SCHEMA_VERSION,
|
|
207
230
|
"_comment": (
|
|
208
231
|
"Suppression ceilings, written by `sarj-ratchet --update`. Counts may "
|
|
209
232
|
"only go DOWN: `codes` is per dialect+code, `packages` is per "
|
|
@@ -283,27 +306,77 @@ def _read(path: Path) -> str:
|
|
|
283
306
|
|
|
284
307
|
|
|
285
308
|
def _count_line(line: str, counts: Counter[str]) -> None:
|
|
286
|
-
|
|
309
|
+
for token in tokenize.generate_tokens(StringIO(line).readline):
|
|
310
|
+
if token.type == tokenize.COMMENT:
|
|
311
|
+
_count_comment(
|
|
312
|
+
token.string,
|
|
313
|
+
counts,
|
|
314
|
+
standalone=not line[: token.start[1]].strip(),
|
|
315
|
+
ruff_aliases=None,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _count_comment(
|
|
320
|
+
comment: str,
|
|
321
|
+
counts: Counter[str],
|
|
322
|
+
*,
|
|
323
|
+
standalone: bool,
|
|
324
|
+
ruff_aliases: Mapping[str, str] | None,
|
|
325
|
+
) -> None:
|
|
326
|
+
if standalone and (file_ignore := _RUFF_FILE_IGNORE_RE.match(comment)):
|
|
327
|
+
counts.update(
|
|
328
|
+
f"file-noqa:{_normalized_ruff_selector(code, ruff_aliases)}" for code in file_ignore.group(1).split(",")
|
|
329
|
+
)
|
|
330
|
+
return
|
|
331
|
+
if standalone and (file_noqa := _FILE_NOQA_RE.match(comment)):
|
|
287
332
|
listed = file_noqa.group(1)
|
|
288
333
|
if listed:
|
|
289
|
-
counts.update(f"file-noqa:{code
|
|
334
|
+
counts.update(f"file-noqa:{_normalized_ruff_selector(code, ruff_aliases)}" for code in listed.split(","))
|
|
290
335
|
else:
|
|
291
336
|
counts[_BLANKET_KEY] += 1
|
|
292
337
|
return
|
|
293
|
-
if
|
|
294
|
-
|
|
338
|
+
if standalone and _FLAKE8_FILE_NOQA_RE.match(comment):
|
|
339
|
+
counts[_FLAKE8_BLANKET_KEY] += 1
|
|
340
|
+
return
|
|
341
|
+
if standalone and (disabled := _RUFF_DISABLE_RE.match(comment)):
|
|
342
|
+
counts.update(
|
|
343
|
+
f"ruff-range:{_normalized_ruff_selector(code, ruff_aliases)}" for code in disabled.group(1).split(",")
|
|
344
|
+
)
|
|
345
|
+
return
|
|
346
|
+
if standalone and _FILE_PYRIGHT_RE.match(comment):
|
|
347
|
+
rules: list[str] = _FILE_PYRIGHT_RULE_RE.findall(comment)
|
|
295
348
|
counts.update(f"file-pyright:{rule}" for rule in rules)
|
|
296
349
|
return
|
|
297
|
-
for
|
|
298
|
-
(
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
350
|
+
for match in _NOQA_RE.finditer(comment):
|
|
351
|
+
listed = match.group(1)
|
|
352
|
+
if listed:
|
|
353
|
+
counts.update(f"noqa:{_normalized_ruff_selector(code, ruff_aliases)}" for code in listed.split(","))
|
|
354
|
+
else:
|
|
355
|
+
counts[_INLINE_BLANKET_KEY] += 1
|
|
356
|
+
for match in _RUFF_IGNORE_RE.finditer(comment):
|
|
357
|
+
prefix = "standalone-noqa:" if standalone else "noqa:"
|
|
358
|
+
counts.update(f"{prefix}{_normalized_ruff_selector(code, ruff_aliases)}" for code in match.group(1).split(","))
|
|
359
|
+
for match in _SARJ_NOQA_RE.finditer(comment):
|
|
360
|
+
counts.update(f"sarj-noqa:{code.strip().upper()}" for code in match.group(1).split(","))
|
|
361
|
+
for match in _PYRIGHT_IGNORE_RE.finditer(comment):
|
|
362
|
+
counts.update(f"pyright:{code.strip()}" for code in match.group(1).split(","))
|
|
363
|
+
if re.search(r"#\s*pyright:\s*ignore\b(?!\s*\[)", comment):
|
|
364
|
+
counts[_BARE_PYRIGHT_IGNORE_KEY] += 1
|
|
365
|
+
for match in _TYPE_IGNORE_RE.finditer(comment):
|
|
305
366
|
listed = match.group(1)
|
|
306
367
|
if listed:
|
|
307
368
|
counts.update(f"type-ignore:{code.strip()}" for code in listed.split(","))
|
|
308
369
|
else:
|
|
309
370
|
counts[_BARE_TYPE_IGNORE_KEY] += 1
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _normalized_ruff_selector(selector: str, aliases: Mapping[str, str] | None) -> str:
|
|
374
|
+
stripped = selector.strip()
|
|
375
|
+
normalized = stripped.upper() if stripped.isupper() else stripped.lower()
|
|
376
|
+
if aliases is None:
|
|
377
|
+
return normalized
|
|
378
|
+
try:
|
|
379
|
+
return aliases[normalized]
|
|
380
|
+
except KeyError as exc:
|
|
381
|
+
msg = f"unknown Ruff suppression selector: {stripped}"
|
|
382
|
+
raise ValueError(msg) from exc
|
|
@@ -111,6 +111,7 @@ from sarj_python_lint.rules.prefer_struct_over_namedtuple import (
|
|
|
111
111
|
from sarj_python_lint.rules.prefer_timedelta_for_durations import (
|
|
112
112
|
PreferTimedeltaForDurations,
|
|
113
113
|
)
|
|
114
|
+
from sarj_python_lint.rules.prefer_walrus_awaited_none_guard import PreferWalrusAwaitedNoneGuard
|
|
114
115
|
from sarj_python_lint.rules.prefer_walrus_comprehension_filter import (
|
|
115
116
|
PreferWalrusComprehensionFilter,
|
|
116
117
|
)
|
|
@@ -162,6 +163,7 @@ if TYPE_CHECKING:
|
|
|
162
163
|
|
|
163
164
|
REGISTRY: Mapping[str, type[Rule]] = MappingProxyType(
|
|
164
165
|
{
|
|
166
|
+
PreferWalrusAwaitedNoneGuard.id: PreferWalrusAwaitedNoneGuard,
|
|
165
167
|
TimestampOrderRequiresTiebreaker.id: TimestampOrderRequiresTiebreaker,
|
|
166
168
|
NoStringConcatInLoop.id: NoStringConcatInLoop,
|
|
167
169
|
PreferClassRow.id: PreferClassRow,
|
|
@@ -15,7 +15,7 @@ from sarj_python_lint.rule_base import (
|
|
|
15
15
|
RuleExample,
|
|
16
16
|
parse_or_none,
|
|
17
17
|
)
|
|
18
|
-
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
18
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path, is_test_support_path
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
@@ -42,8 +42,9 @@ class NoRawConnectionInTests(Rule):
|
|
|
42
42
|
category=RuleCategory.TESTING,
|
|
43
43
|
autofix=AutofixPolicy.NONE,
|
|
44
44
|
limitations=(
|
|
45
|
-
"Only test paths are inspected.",
|
|
45
|
+
"Only test paths outside conftest.py and conventional shared test-support modules are inspected.",
|
|
46
46
|
"A receiver is reported only when a parameter, annotated local, or constructor call proves it is a psycopg ConnectionPool or AsyncConnectionPool.",
|
|
47
|
+
"Pytest fixtures may use a connection internally for setup and cleanup, but fixtures that return or yield the connection remain reportable.",
|
|
47
48
|
),
|
|
48
49
|
examples=(
|
|
49
50
|
RuleExample(
|
|
@@ -80,7 +81,12 @@ class NoRawConnectionInTests(Rule):
|
|
|
80
81
|
|
|
81
82
|
@override
|
|
82
83
|
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
83
|
-
if
|
|
84
|
+
if (
|
|
85
|
+
not is_test_path(path)
|
|
86
|
+
or path.name == "conftest.py"
|
|
87
|
+
or is_test_support_path(path)
|
|
88
|
+
or is_generated(path, source)
|
|
89
|
+
):
|
|
84
90
|
return []
|
|
85
91
|
tree = parse_or_none(path, source)
|
|
86
92
|
if tree is None:
|
|
@@ -109,6 +115,7 @@ class NoRawConnectionInTests(Rule):
|
|
|
109
115
|
and node.func.attr == "connection"
|
|
110
116
|
and isinstance(node.func.value, ast.Name)
|
|
111
117
|
and node.func.value.id in pool_names
|
|
118
|
+
and not _is_internal_fixture_connection(scope, node)
|
|
112
119
|
)
|
|
113
120
|
return sorted(diagnostics, key=lambda item: (item.line, item.col))
|
|
114
121
|
|
|
@@ -152,3 +159,40 @@ def _tail(node: ast.expr | None) -> str:
|
|
|
152
159
|
return _tail(value)
|
|
153
160
|
case _:
|
|
154
161
|
return ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _is_internal_fixture_connection(
|
|
165
|
+
scope: ast.Module | ast.FunctionDef | ast.AsyncFunctionDef, connection_call: ast.Call
|
|
166
|
+
) -> bool:
|
|
167
|
+
if not isinstance(scope, (ast.FunctionDef, ast.AsyncFunctionDef)) or not _is_pytest_fixture(scope):
|
|
168
|
+
return False
|
|
169
|
+
context_item = next(
|
|
170
|
+
(
|
|
171
|
+
item
|
|
172
|
+
for node in _scope_nodes(scope)
|
|
173
|
+
if isinstance(node, (ast.With, ast.AsyncWith))
|
|
174
|
+
for item in node.items
|
|
175
|
+
if item.context_expr is connection_call
|
|
176
|
+
),
|
|
177
|
+
None,
|
|
178
|
+
)
|
|
179
|
+
if context_item is None:
|
|
180
|
+
return False
|
|
181
|
+
if context_item.optional_vars is None:
|
|
182
|
+
return True
|
|
183
|
+
if not isinstance(context_item.optional_vars, ast.Name):
|
|
184
|
+
return False
|
|
185
|
+
bound_name = context_item.optional_vars.id
|
|
186
|
+
return not any(
|
|
187
|
+
isinstance(node, (ast.Return, ast.Yield, ast.YieldFrom))
|
|
188
|
+
and node.value is not None
|
|
189
|
+
and any(isinstance(value, ast.Name) and value.id == bound_name for value in ast.walk(node.value))
|
|
190
|
+
for node in _scope_nodes(scope)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _is_pytest_fixture(scope: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
195
|
+
return any(
|
|
196
|
+
_tail(decorator.func if isinstance(decorator, ast.Call) else decorator) == "fixture"
|
|
197
|
+
for decorator in scope.decorator_list
|
|
198
|
+
)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from itertools import pairwise
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
import tokenize
|
|
8
|
+
from typing import TYPE_CHECKING, ClassVar, NamedTuple, final, override
|
|
9
|
+
|
|
10
|
+
from sarj_python_lint.rule_base import (
|
|
11
|
+
AutofixPolicy,
|
|
12
|
+
Diagnostic,
|
|
13
|
+
ExampleFile,
|
|
14
|
+
ExampleOutcome,
|
|
15
|
+
Rule,
|
|
16
|
+
RuleCategory,
|
|
17
|
+
RuleDocumentation,
|
|
18
|
+
RuleExample,
|
|
19
|
+
Severity,
|
|
20
|
+
is_suppressed,
|
|
21
|
+
parse_or_none,
|
|
22
|
+
)
|
|
23
|
+
from sarj_python_lint.rules._paths import is_generated
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_MAX_COMBINED_LINE_LENGTH = 120
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _WalrusCandidate(NamedTuple):
|
|
34
|
+
name: str
|
|
35
|
+
awaited: ast.Await
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@final
|
|
39
|
+
class PreferWalrusAwaitedNoneGuard(Rule):
|
|
40
|
+
id = "prefer-walrus-awaited-none-guard"
|
|
41
|
+
code = "SARJ432"
|
|
42
|
+
documentation: ClassVar[RuleDocumentation | None] = RuleDocumentation(
|
|
43
|
+
summary="Bind a compact awaited lookup in its immediately following None guard.",
|
|
44
|
+
rationale=(
|
|
45
|
+
"When an awaited lookup and its mandatory absence guard are adjacent, binding in the condition keeps "
|
|
46
|
+
"the operation and the reason for the temporary name in one compact expression."
|
|
47
|
+
),
|
|
48
|
+
remediation=(
|
|
49
|
+
"Rewrite `value = await lookup(); if value is None: return` as "
|
|
50
|
+
"`if (value := await lookup()) is None: return`."
|
|
51
|
+
),
|
|
52
|
+
category=RuleCategory.STYLE,
|
|
53
|
+
autofix=AutofixPolicy.NONE,
|
|
54
|
+
limitations=(
|
|
55
|
+
"Only one-line awaited calls followed immediately by an exact `is None` guard containing one return are checked.",
|
|
56
|
+
"The combined condition must fit 120 columns; comments, else branches, rebinding, generated files, and broader assignment-expression preferences are excluded.",
|
|
57
|
+
),
|
|
58
|
+
examples=(
|
|
59
|
+
RuleExample(
|
|
60
|
+
example_id="awaited-optional-lookup",
|
|
61
|
+
title="An awaited lookup is split from its absence guard",
|
|
62
|
+
outcome=ExampleOutcome.MATCH,
|
|
63
|
+
files=(
|
|
64
|
+
ExampleFile.python(
|
|
65
|
+
"service.py",
|
|
66
|
+
"async def load(store, item_id):\n"
|
|
67
|
+
" item = await store.get(item_id)\n"
|
|
68
|
+
" if item is None:\n"
|
|
69
|
+
" return\n"
|
|
70
|
+
" return item.name\n",
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
focus_path=PurePosixPath("service.py"),
|
|
74
|
+
expected_count=1,
|
|
75
|
+
public=True,
|
|
76
|
+
),
|
|
77
|
+
RuleExample(
|
|
78
|
+
example_id="guard-bound-lookup",
|
|
79
|
+
title="The awaited lookup is bound in its absence guard",
|
|
80
|
+
outcome=ExampleOutcome.NO_MATCH,
|
|
81
|
+
files=(
|
|
82
|
+
ExampleFile.python(
|
|
83
|
+
"service.py",
|
|
84
|
+
"async def load(store, item_id):\n"
|
|
85
|
+
" if (item := await store.get(item_id)) is None:\n"
|
|
86
|
+
" return\n"
|
|
87
|
+
" return item.name\n",
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
focus_path=PurePosixPath("service.py"),
|
|
91
|
+
expected_count=0,
|
|
92
|
+
public=True,
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
description = documentation.summary
|
|
97
|
+
|
|
98
|
+
@override
|
|
99
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
100
|
+
if path.suffix == ".pyi" or is_generated(path, source):
|
|
101
|
+
return []
|
|
102
|
+
tree = parse_or_none(path, source)
|
|
103
|
+
if tree is None:
|
|
104
|
+
return []
|
|
105
|
+
source_lines = source.splitlines()
|
|
106
|
+
comment_lines = _comment_lines(source)
|
|
107
|
+
diagnostics: list[Diagnostic] = []
|
|
108
|
+
for body in _statement_lists(tree):
|
|
109
|
+
for index, (assignment, guard) in enumerate(pairwise(body)):
|
|
110
|
+
candidate = _candidate(assignment, guard, source, source_lines, comment_lines)
|
|
111
|
+
if candidate is None:
|
|
112
|
+
continue
|
|
113
|
+
name, awaited = candidate
|
|
114
|
+
if not _used_before_rebinding(body[index + 2 :], name):
|
|
115
|
+
continue
|
|
116
|
+
if is_suppressed(source_lines, assignment.lineno, self.code) or is_suppressed(
|
|
117
|
+
source_lines, guard.lineno, self.code
|
|
118
|
+
):
|
|
119
|
+
continue
|
|
120
|
+
expression = ast.get_source_segment(source, awaited)
|
|
121
|
+
if expression is None:
|
|
122
|
+
continue
|
|
123
|
+
diagnostics.append(
|
|
124
|
+
Diagnostic(
|
|
125
|
+
path=path,
|
|
126
|
+
line=assignment.lineno,
|
|
127
|
+
col=assignment.col_offset + 1,
|
|
128
|
+
code=self.code,
|
|
129
|
+
severity=Severity.WARNING,
|
|
130
|
+
message=(
|
|
131
|
+
f"`{name}` only separates an awaited lookup from its None guard; "
|
|
132
|
+
f"bind it as `if ({name} := {expression.strip()}) is None:`"
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return sorted(diagnostics, key=lambda item: (item.line, item.col))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _candidate(
|
|
140
|
+
assignment: ast.stmt,
|
|
141
|
+
guard: ast.stmt,
|
|
142
|
+
source: str,
|
|
143
|
+
source_lines: list[str],
|
|
144
|
+
comment_lines: frozenset[int],
|
|
145
|
+
) -> _WalrusCandidate | None:
|
|
146
|
+
if not (
|
|
147
|
+
isinstance(assignment, ast.Assign)
|
|
148
|
+
and len(assignment.targets) == 1
|
|
149
|
+
and isinstance(assignment.targets[0], ast.Name)
|
|
150
|
+
and isinstance(assignment.value, ast.Await)
|
|
151
|
+
and isinstance(assignment.value.value, ast.Call)
|
|
152
|
+
and assignment.end_lineno is not None
|
|
153
|
+
and assignment.lineno == assignment.end_lineno
|
|
154
|
+
and isinstance(guard, ast.If)
|
|
155
|
+
and guard.lineno == assignment.end_lineno + 1
|
|
156
|
+
and guard.col_offset == assignment.col_offset
|
|
157
|
+
and guard.test.end_lineno == guard.lineno
|
|
158
|
+
and not guard.orelse
|
|
159
|
+
and len(guard.body) == 1
|
|
160
|
+
and isinstance(guard.body[0], ast.Return)
|
|
161
|
+
):
|
|
162
|
+
return None
|
|
163
|
+
name = assignment.targets[0].id
|
|
164
|
+
if not _is_none_guard(guard.test, name) or _loads_name(assignment.value, name) or _loads_name(guard.body[0], name):
|
|
165
|
+
return None
|
|
166
|
+
if comment_lines.intersection(range(assignment.lineno, guard.lineno + 1)):
|
|
167
|
+
return None
|
|
168
|
+
expression = ast.get_source_segment(source, assignment.value)
|
|
169
|
+
if expression is None:
|
|
170
|
+
return None
|
|
171
|
+
original_line = source_lines[assignment.lineno - 1]
|
|
172
|
+
indentation = original_line[: len(original_line) - len(original_line.lstrip())].expandtabs(8)
|
|
173
|
+
combined = f"{indentation}if ({name} := {expression.strip()}) is None:"
|
|
174
|
+
if len(combined) > _MAX_COMBINED_LINE_LENGTH:
|
|
175
|
+
return None
|
|
176
|
+
return _WalrusCandidate(name, assignment.value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_none_guard(test: ast.expr, name: str) -> bool:
|
|
180
|
+
return (
|
|
181
|
+
isinstance(test, ast.Compare)
|
|
182
|
+
and isinstance(test.left, ast.Name)
|
|
183
|
+
and test.left.id == name
|
|
184
|
+
and len(test.ops) == 1
|
|
185
|
+
and isinstance(test.ops[0], ast.Is)
|
|
186
|
+
and len(test.comparators) == 1
|
|
187
|
+
and isinstance(test.comparators[0], ast.Constant)
|
|
188
|
+
and test.comparators[0].value is None
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _loads_name(node: ast.AST, name: str) -> bool:
|
|
193
|
+
return any(
|
|
194
|
+
isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load) and child.id == name for child in ast.walk(node)
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _used_before_rebinding(statements: list[ast.stmt], name: str) -> bool:
|
|
199
|
+
for statement in statements:
|
|
200
|
+
usage = _NameUsage(name)
|
|
201
|
+
usage.visit(statement)
|
|
202
|
+
if usage.rebound:
|
|
203
|
+
return False
|
|
204
|
+
if usage.loaded:
|
|
205
|
+
return True
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@final
|
|
210
|
+
class _NameUsage(ast.NodeVisitor):
|
|
211
|
+
def __init__(self, name: str) -> None:
|
|
212
|
+
self.name = name
|
|
213
|
+
self.loaded = False
|
|
214
|
+
self.rebound = False
|
|
215
|
+
|
|
216
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
217
|
+
if node.id != self.name:
|
|
218
|
+
return
|
|
219
|
+
if isinstance(node.ctx, ast.Load):
|
|
220
|
+
self.loaded = True
|
|
221
|
+
elif isinstance(node.ctx, (ast.Store, ast.Del)):
|
|
222
|
+
self.rebound = True
|
|
223
|
+
|
|
224
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
225
|
+
self.rebound |= node.name == self.name
|
|
226
|
+
|
|
227
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
228
|
+
self.rebound |= node.name == self.name
|
|
229
|
+
|
|
230
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
231
|
+
self.rebound |= node.name == self.name
|
|
232
|
+
|
|
233
|
+
@override
|
|
234
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
235
|
+
_ = node
|
|
236
|
+
|
|
237
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
238
|
+
self.rebound |= any((alias.asname or alias.name.split(".", maxsplit=1)[0]) == self.name for alias in node.names)
|
|
239
|
+
|
|
240
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
241
|
+
self.rebound |= any((alias.asname or alias.name) == self.name for alias in node.names)
|
|
242
|
+
|
|
243
|
+
def visit_ExceptHandler(self, node: ast.ExceptHandler) -> None:
|
|
244
|
+
if node.name == self.name:
|
|
245
|
+
self.rebound = True
|
|
246
|
+
return
|
|
247
|
+
self.generic_visit(node)
|
|
248
|
+
|
|
249
|
+
def visit_MatchAs(self, node: ast.MatchAs) -> None:
|
|
250
|
+
if node.name == self.name:
|
|
251
|
+
self.rebound = True
|
|
252
|
+
return
|
|
253
|
+
self.generic_visit(node)
|
|
254
|
+
|
|
255
|
+
def visit_MatchStar(self, node: ast.MatchStar) -> None:
|
|
256
|
+
self.rebound |= node.name == self.name
|
|
257
|
+
|
|
258
|
+
def visit_MatchMapping(self, node: ast.MatchMapping) -> None:
|
|
259
|
+
if node.rest == self.name:
|
|
260
|
+
self.rebound = True
|
|
261
|
+
return
|
|
262
|
+
self.generic_visit(node)
|
|
263
|
+
|
|
264
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
265
|
+
self.rebound |= self.name in node.names
|
|
266
|
+
|
|
267
|
+
def visit_Nonlocal(self, node: ast.Nonlocal) -> None:
|
|
268
|
+
self.rebound |= self.name in node.names
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _statement_lists(tree: ast.AST) -> list[list[ast.stmt]]:
|
|
272
|
+
result: list[list[ast.stmt]] = []
|
|
273
|
+
for node in ast.walk(tree):
|
|
274
|
+
for field in ("body", "orelse", "finalbody"):
|
|
275
|
+
value = getattr(node, field, None)
|
|
276
|
+
if not isinstance(value, list):
|
|
277
|
+
continue
|
|
278
|
+
statements: list[ast.stmt] = []
|
|
279
|
+
for item in value: # pyright: ignore[reportUnknownVariableType] -- AST fields are untyped lists
|
|
280
|
+
if not isinstance(item, ast.stmt):
|
|
281
|
+
break
|
|
282
|
+
statements.append(item)
|
|
283
|
+
else:
|
|
284
|
+
if statements:
|
|
285
|
+
result.append(statements)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _comment_lines(source: str) -> frozenset[int]:
|
|
290
|
+
try:
|
|
291
|
+
tokens = tokenize.generate_tokens(StringIO(source).readline)
|
|
292
|
+
return frozenset(token.start[0] for token in tokens if token.type == tokenize.COMMENT)
|
|
293
|
+
except IndentationError, tokenize.TokenError:
|
|
294
|
+
return frozenset()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/_analysis_session.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_project_index.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_prose_budget.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/_test_assertions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_long_comment.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/no_stdlib_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_class_row.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_or_pattern.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/prefer_str_enum.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.79.0 → sarj_python_lint-0.80.0}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|