sarj-python-lint 0.44.0__tar.gz → 0.45.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.44.0 → sarj_python_lint-0.45.0}/PKG-INFO +26 -2
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/README.md +25 -1
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/pyproject.toml +1 -1
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_registry.py +4 -0
- sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +701 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/.gitignore +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/__init__.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/__main__.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_secret_names.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_version.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/py.typed +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/ratchet.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rule_base.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/__init__.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_comments.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_logging.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_paths.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_sql.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
- {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/zero_assertion_test.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sarj-python-lint
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.45.0
|
|
4
4
|
Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
|
|
5
5
|
Project-URL: Homepage, https://github.com/sarj-ai/standards/tree/main/packages/python
|
|
6
6
|
Project-URL: Repository, https://github.com/sarj-ai/standards
|
|
@@ -28,7 +28,7 @@ uv tool install sarj-python-lint
|
|
|
28
28
|
|
|
29
29
|
```yaml
|
|
30
30
|
- repo: https://github.com/sarj-ai/standards
|
|
31
|
-
rev: python-v0.
|
|
31
|
+
rev: python-v0.45.0
|
|
32
32
|
hooks:
|
|
33
33
|
- id: sarj-no-sequential-await
|
|
34
34
|
- id: sarj-inefficient-string-concat-in-loop
|
|
@@ -36,6 +36,7 @@ uv tool install sarj-python-lint
|
|
|
36
36
|
- id: sarj-no-fat-try-blocks
|
|
37
37
|
- id: sarj-pydantic-at-boundaries
|
|
38
38
|
- id: sarj-fastapi-openapi-contract # SARJ094
|
|
39
|
+
- id: sarj-no-hidden-constructor-fallback # SARJ095 (warning)
|
|
39
40
|
- id: sarj-prefer-class-row
|
|
40
41
|
- id: sarj-prefer-timedelta-for-durations
|
|
41
42
|
- id: sarj-prefer-struct-over-namedtuple
|
|
@@ -61,6 +62,29 @@ function-local framework imports, imported router instances and the assembled
|
|
|
61
62
|
Existing projects can adopt the default-enabled rule with `--update-baseline`
|
|
62
63
|
and then shrink that baseline as endpoint contracts are repaired.
|
|
63
64
|
|
|
65
|
+
### Hidden constructor settings fallback (0.45.0)
|
|
66
|
+
|
|
67
|
+
`SARJ095` warns when a keyword-only constructor parameter defaults to `None`
|
|
68
|
+
and the constructor silently replaces it with a proven pydantic-settings value.
|
|
69
|
+
The effective dependency is invisible at the call site, and `value or
|
|
70
|
+
settings.VALUE` also treats an explicit falsey value as omitted. Make the
|
|
71
|
+
argument required and resolve the setting at the application composition root;
|
|
72
|
+
the annotation may remain nullable when `None` is still a valid explicit value.
|
|
73
|
+
|
|
74
|
+
The rule resolves same-module settings objects, imports, aliases and re-exports
|
|
75
|
+
back to an instance of a `pydantic_settings.BaseSettings` subclass. Literal and
|
|
76
|
+
enum defaults, mutable-container initialization, arbitrary factories and
|
|
77
|
+
clients, module constants, environment-variable APIs, other parameters and
|
|
78
|
+
instance state are deliberately outside v1. No autofix is offered because
|
|
79
|
+
changing constructor optionality requires coordinated call-site edits.
|
|
80
|
+
|
|
81
|
+
Measured over 4,638 tracked Python files in 33 first-party repositories: three
|
|
82
|
+
constructor warnings across two repositories, all three actionable. A pinned
|
|
83
|
+
15-repository OSS sweep covered 29,203 files and produced zero reports, which is
|
|
84
|
+
compatibility evidence rather than a precision claim. An environment-variable
|
|
85
|
+
arm was rejected before shipping: it reported a public first-party library and
|
|
86
|
+
two intentional LiteLLM integration constructors, all non-actionable.
|
|
87
|
+
|
|
64
88
|
### Test-quality rules (0.15.0)
|
|
65
89
|
|
|
66
90
|
Mined from an AST audit of ~7,500 test functions across two production repos.
|
|
@@ -10,7 +10,7 @@ uv tool install sarj-python-lint
|
|
|
10
10
|
|
|
11
11
|
```yaml
|
|
12
12
|
- repo: https://github.com/sarj-ai/standards
|
|
13
|
-
rev: python-v0.
|
|
13
|
+
rev: python-v0.45.0
|
|
14
14
|
hooks:
|
|
15
15
|
- id: sarj-no-sequential-await
|
|
16
16
|
- id: sarj-inefficient-string-concat-in-loop
|
|
@@ -18,6 +18,7 @@ uv tool install sarj-python-lint
|
|
|
18
18
|
- id: sarj-no-fat-try-blocks
|
|
19
19
|
- id: sarj-pydantic-at-boundaries
|
|
20
20
|
- id: sarj-fastapi-openapi-contract # SARJ094
|
|
21
|
+
- id: sarj-no-hidden-constructor-fallback # SARJ095 (warning)
|
|
21
22
|
- id: sarj-prefer-class-row
|
|
22
23
|
- id: sarj-prefer-timedelta-for-durations
|
|
23
24
|
- id: sarj-prefer-struct-over-namedtuple
|
|
@@ -43,6 +44,29 @@ function-local framework imports, imported router instances and the assembled
|
|
|
43
44
|
Existing projects can adopt the default-enabled rule with `--update-baseline`
|
|
44
45
|
and then shrink that baseline as endpoint contracts are repaired.
|
|
45
46
|
|
|
47
|
+
### Hidden constructor settings fallback (0.45.0)
|
|
48
|
+
|
|
49
|
+
`SARJ095` warns when a keyword-only constructor parameter defaults to `None`
|
|
50
|
+
and the constructor silently replaces it with a proven pydantic-settings value.
|
|
51
|
+
The effective dependency is invisible at the call site, and `value or
|
|
52
|
+
settings.VALUE` also treats an explicit falsey value as omitted. Make the
|
|
53
|
+
argument required and resolve the setting at the application composition root;
|
|
54
|
+
the annotation may remain nullable when `None` is still a valid explicit value.
|
|
55
|
+
|
|
56
|
+
The rule resolves same-module settings objects, imports, aliases and re-exports
|
|
57
|
+
back to an instance of a `pydantic_settings.BaseSettings` subclass. Literal and
|
|
58
|
+
enum defaults, mutable-container initialization, arbitrary factories and
|
|
59
|
+
clients, module constants, environment-variable APIs, other parameters and
|
|
60
|
+
instance state are deliberately outside v1. No autofix is offered because
|
|
61
|
+
changing constructor optionality requires coordinated call-site edits.
|
|
62
|
+
|
|
63
|
+
Measured over 4,638 tracked Python files in 33 first-party repositories: three
|
|
64
|
+
constructor warnings across two repositories, all three actionable. A pinned
|
|
65
|
+
15-repository OSS sweep covered 29,203 files and produced zero reports, which is
|
|
66
|
+
compatibility evidence rather than a precision claim. An environment-variable
|
|
67
|
+
arm was rejected before shipping: it reported a public first-party library and
|
|
68
|
+
two intentional LiteLLM integration constructors, all non-actionable.
|
|
69
|
+
|
|
46
70
|
### Test-quality rules (0.15.0)
|
|
47
71
|
|
|
48
72
|
Mined from an AST audit of ~7,500 test functions across two production repos.
|
|
@@ -37,6 +37,9 @@ from sarj_python_lint.rules.no_first_party_private_import import (
|
|
|
37
37
|
)
|
|
38
38
|
from sarj_python_lint.rules.no_fstring_in_log import NoFstringInLog
|
|
39
39
|
from sarj_python_lint.rules.no_gen_random_uuid_in_sql import NoGenRandomUuidInSql
|
|
40
|
+
from sarj_python_lint.rules.no_hidden_constructor_fallback import (
|
|
41
|
+
NoHiddenConstructorFallback,
|
|
42
|
+
)
|
|
40
43
|
from sarj_python_lint.rules.no_isinstance_union_chain import NoIsinstanceUnionChain
|
|
41
44
|
from sarj_python_lint.rules.no_long_comment import NoLongComment
|
|
42
45
|
from sarj_python_lint.rules.no_offset_pagination import NoOffsetPagination
|
|
@@ -167,6 +170,7 @@ REGISTRY: dict[str, type[Rule]] = {
|
|
|
167
170
|
TrailingValueNarration.id: TrailingValueNarration,
|
|
168
171
|
NoStdlibLogging.id: NoStdlibLogging,
|
|
169
172
|
NoGenRandomUuidInSql.id: NoGenRandomUuidInSql,
|
|
173
|
+
NoHiddenConstructorFallback.id: NoHiddenConstructorFallback,
|
|
170
174
|
NoFileLevelEscapeHatchNoqa.id: NoFileLevelEscapeHatchNoqa,
|
|
171
175
|
NoOptionalTenantPredicate.id: NoOptionalTenantPredicate,
|
|
172
176
|
NoTautologicalExpect.id: NoTautologicalExpect,
|
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
"""SARJ095 — Constructor options must not hide runtime configuration fallback.
|
|
2
|
+
|
|
3
|
+
Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_no_hidden_constructor_fallback.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, final, override
|
|
14
|
+
|
|
15
|
+
from sarj_python_lint.rule_base import Diagnostic, Rule, Severity, parse_or_none
|
|
16
|
+
from sarj_python_lint.rules._ast_index import nodes
|
|
17
|
+
from sarj_python_lint.rules._first_party import distribution_root
|
|
18
|
+
from sarj_python_lint.rules._paths import is_generated, is_test_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_MIGRATION_PARTS = frozenset({"alembic", "migration", "migrations", "versions"})
|
|
26
|
+
_DESCRIPTOR_DECORATORS = frozenset({"classmethod", "staticmethod"})
|
|
27
|
+
_QUALIFIED_NAME_PARTS = 2
|
|
28
|
+
_SCAN_SKIP_PARTS = frozenset(
|
|
29
|
+
{
|
|
30
|
+
".git",
|
|
31
|
+
".mypy_cache",
|
|
32
|
+
".nox",
|
|
33
|
+
".pytest_cache",
|
|
34
|
+
".ruff_cache",
|
|
35
|
+
".tox",
|
|
36
|
+
".uv-cache",
|
|
37
|
+
".venv",
|
|
38
|
+
"build",
|
|
39
|
+
"dist",
|
|
40
|
+
"generated",
|
|
41
|
+
"node_modules",
|
|
42
|
+
"site-packages",
|
|
43
|
+
"vendor",
|
|
44
|
+
"venv",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class _Binding:
|
|
51
|
+
module: str
|
|
52
|
+
symbol: str | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@final
|
|
56
|
+
class NoHiddenConstructorFallback(Rule):
|
|
57
|
+
id = "no-hidden-constructor-fallback"
|
|
58
|
+
code = "SARJ095"
|
|
59
|
+
description = (
|
|
60
|
+
"A keyword-only constructor option defaults to `None` and silently resolves from application settings."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@override
|
|
64
|
+
def check(self, path: Path, source: str) -> list[Diagnostic]:
|
|
65
|
+
if (
|
|
66
|
+
is_test_path(path)
|
|
67
|
+
or is_generated(path, source)
|
|
68
|
+
or any(part.lower() in _MIGRATION_PARTS for part in path.parts)
|
|
69
|
+
):
|
|
70
|
+
return []
|
|
71
|
+
tree = parse_or_none(path, source)
|
|
72
|
+
if tree is None:
|
|
73
|
+
return []
|
|
74
|
+
|
|
75
|
+
resolver = _RuntimeConfigResolver(path, tree)
|
|
76
|
+
diagnostics: list[Diagnostic] = []
|
|
77
|
+
for class_node in nodes(tree, ast.ClassDef):
|
|
78
|
+
init = next(
|
|
79
|
+
(
|
|
80
|
+
statement
|
|
81
|
+
for statement in reversed(class_node.body)
|
|
82
|
+
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) and statement.name == "__init__"
|
|
83
|
+
),
|
|
84
|
+
None,
|
|
85
|
+
)
|
|
86
|
+
if init is None or _is_descriptor(init):
|
|
87
|
+
continue
|
|
88
|
+
hidden = _hidden_parameters(init, resolver)
|
|
89
|
+
if not hidden or not _has_composition_call(path, class_node.name):
|
|
90
|
+
continue
|
|
91
|
+
names = ", ".join(f"`{parameter.arg}`" for parameter in hidden)
|
|
92
|
+
noun = "parameter" if len(hidden) == 1 else "parameters"
|
|
93
|
+
verb = "falls" if len(hidden) == 1 else "fall"
|
|
94
|
+
diagnostics.append(
|
|
95
|
+
Diagnostic(
|
|
96
|
+
path=path,
|
|
97
|
+
line=hidden[0].lineno,
|
|
98
|
+
col=hidden[0].col_offset + 1,
|
|
99
|
+
code=self.code,
|
|
100
|
+
message=(
|
|
101
|
+
f"Constructor {noun} {names} {verb} back to application settings when omitted; "
|
|
102
|
+
"make the argument required and resolve the fallback at the call site or composition root. "
|
|
103
|
+
"A boolean `or` also treats explicit falsey values as omitted."
|
|
104
|
+
),
|
|
105
|
+
severity=Severity.WARNING,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
return sorted(diagnostics, key=lambda diagnostic: (diagnostic.line, diagnostic.col))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_descriptor(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
112
|
+
return any(_tail(decorator) in _DESCRIPTOR_DECORATORS for decorator in node.decorator_list)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _hidden_parameters(
|
|
116
|
+
init: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
117
|
+
resolver: _RuntimeConfigResolver,
|
|
118
|
+
) -> list[ast.arg]:
|
|
119
|
+
candidates = {
|
|
120
|
+
parameter.arg: parameter
|
|
121
|
+
for parameter, default in zip(init.args.kwonlyargs, init.args.kw_defaults, strict=True)
|
|
122
|
+
if isinstance(default, ast.Constant) and default.value is None
|
|
123
|
+
}
|
|
124
|
+
if not candidates:
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
hidden: set[str] = set()
|
|
128
|
+
rebound: set[str] = set()
|
|
129
|
+
shadowed = _argument_names(init.args)
|
|
130
|
+
for statement in init.body:
|
|
131
|
+
available = candidates.keys() - rebound
|
|
132
|
+
hidden.update(_statement_fallbacks(statement, available, resolver, shadowed))
|
|
133
|
+
rebound.update(_directly_bound_names(statement) & candidates.keys())
|
|
134
|
+
shadowed.update(_directly_bound_names(statement))
|
|
135
|
+
return [parameter for name, parameter in candidates.items() if name in hidden]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _statement_fallbacks(
|
|
139
|
+
statement: ast.stmt,
|
|
140
|
+
candidates: set[str],
|
|
141
|
+
resolver: _RuntimeConfigResolver,
|
|
142
|
+
shadowed: set[str],
|
|
143
|
+
) -> set[str]:
|
|
144
|
+
if isinstance(statement, ast.Assign):
|
|
145
|
+
return _expression_fallbacks(statement.value, candidates, resolver, shadowed)
|
|
146
|
+
if isinstance(statement, ast.AnnAssign) and statement.value is not None:
|
|
147
|
+
return _expression_fallbacks(statement.value, candidates, resolver, shadowed)
|
|
148
|
+
if not isinstance(statement, ast.If) or statement.orelse or len(statement.body) != 1:
|
|
149
|
+
return set()
|
|
150
|
+
parameter = _none_comparison_parameter(statement.test, candidates, expect_not=False)
|
|
151
|
+
if parameter is None:
|
|
152
|
+
return set()
|
|
153
|
+
body = statement.body[0]
|
|
154
|
+
if isinstance(body, ast.Assign):
|
|
155
|
+
value = body.value if len(body.targets) == 1 and _is_name(body.targets[0], parameter) else None
|
|
156
|
+
elif isinstance(body, ast.AnnAssign):
|
|
157
|
+
value = body.value if _is_name(body.target, parameter) else None
|
|
158
|
+
else:
|
|
159
|
+
value = None
|
|
160
|
+
return {parameter} if value is not None and resolver.is_runtime_config(value, shadowed) else set()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _expression_fallbacks(
|
|
164
|
+
expression: ast.expr,
|
|
165
|
+
candidates: set[str],
|
|
166
|
+
resolver: _RuntimeConfigResolver,
|
|
167
|
+
shadowed: set[str],
|
|
168
|
+
) -> set[str]:
|
|
169
|
+
if isinstance(expression, ast.BoolOp) and isinstance(expression.op, ast.Or):
|
|
170
|
+
match expression.values:
|
|
171
|
+
case [ast.Name(id=parameter), fallback] if parameter in candidates:
|
|
172
|
+
return {parameter} if resolver.is_runtime_config(fallback, shadowed) else set()
|
|
173
|
+
case _:
|
|
174
|
+
return set()
|
|
175
|
+
if not isinstance(expression, ast.IfExp):
|
|
176
|
+
return set()
|
|
177
|
+
not_none = _none_comparison_parameter(expression.test, candidates, expect_not=True)
|
|
178
|
+
if (
|
|
179
|
+
not_none is not None
|
|
180
|
+
and isinstance(expression.body, ast.Name)
|
|
181
|
+
and expression.body.id == not_none
|
|
182
|
+
and resolver.is_runtime_config(expression.orelse, shadowed)
|
|
183
|
+
):
|
|
184
|
+
return {not_none}
|
|
185
|
+
is_none = _none_comparison_parameter(expression.test, candidates, expect_not=False)
|
|
186
|
+
if (
|
|
187
|
+
is_none is not None
|
|
188
|
+
and isinstance(expression.orelse, ast.Name)
|
|
189
|
+
and expression.orelse.id == is_none
|
|
190
|
+
and resolver.is_runtime_config(expression.body, shadowed)
|
|
191
|
+
):
|
|
192
|
+
return {is_none}
|
|
193
|
+
return set()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _none_comparison_parameter(test: ast.expr, candidates: set[str], *, expect_not: bool) -> str | None:
|
|
197
|
+
if not isinstance(test, ast.Compare) or len(test.ops) != 1 or len(test.comparators) != 1:
|
|
198
|
+
return None
|
|
199
|
+
operator = test.ops[0]
|
|
200
|
+
if expect_not != isinstance(operator, ast.IsNot):
|
|
201
|
+
return None
|
|
202
|
+
if not isinstance(operator, (ast.Is, ast.IsNot)):
|
|
203
|
+
return None
|
|
204
|
+
pairs = ((test.left, test.comparators[0]), (test.comparators[0], test.left))
|
|
205
|
+
return next(
|
|
206
|
+
(
|
|
207
|
+
name.id
|
|
208
|
+
for name, none_value in pairs
|
|
209
|
+
if isinstance(name, ast.Name)
|
|
210
|
+
and name.id in candidates
|
|
211
|
+
and isinstance(none_value, ast.Constant)
|
|
212
|
+
and none_value.value is None
|
|
213
|
+
),
|
|
214
|
+
None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _directly_bound_names(statement: ast.stmt) -> set[str]:
|
|
219
|
+
if isinstance(statement, ast.Assign):
|
|
220
|
+
return {name for target in statement.targets for name in _target_names(target)}
|
|
221
|
+
if isinstance(statement, ast.AnnAssign):
|
|
222
|
+
return _target_names(statement.target)
|
|
223
|
+
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
224
|
+
return {statement.name}
|
|
225
|
+
return set()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _argument_names(arguments: ast.arguments) -> set[str]:
|
|
229
|
+
names = {argument.arg for argument in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs)}
|
|
230
|
+
if arguments.vararg is not None:
|
|
231
|
+
names.add(arguments.vararg.arg)
|
|
232
|
+
if arguments.kwarg is not None:
|
|
233
|
+
names.add(arguments.kwarg.arg)
|
|
234
|
+
return names
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _target_names(target: ast.expr) -> set[str]:
|
|
238
|
+
if isinstance(target, ast.Name):
|
|
239
|
+
return {target.id}
|
|
240
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
241
|
+
return {name for element in target.elts for name in _target_names(element)}
|
|
242
|
+
return set()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _is_name(expression: ast.expr, name: str) -> bool:
|
|
246
|
+
return isinstance(expression, ast.Name) and expression.id == name
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@final
|
|
250
|
+
class _RuntimeConfigResolver:
|
|
251
|
+
def __init__(self, path: Path, tree: ast.Module) -> None:
|
|
252
|
+
self._path = path
|
|
253
|
+
self._tree = tree
|
|
254
|
+
self._root = distribution_root(path)
|
|
255
|
+
self._module = _module_name(path, self._root)
|
|
256
|
+
self._imports = _imports(tree, self._module, is_package=path.name == "__init__.py")
|
|
257
|
+
self._settings_cache: dict[tuple[str, str], bool] = {}
|
|
258
|
+
self._settings_class_cache: dict[tuple[str, str], bool] = {}
|
|
259
|
+
|
|
260
|
+
def is_runtime_config(self, expression: ast.expr, shadowed: set[str]) -> bool:
|
|
261
|
+
return self._is_settings_attribute(expression, shadowed)
|
|
262
|
+
|
|
263
|
+
def _is_settings_attribute(self, expression: ast.expr, shadowed: set[str]) -> bool:
|
|
264
|
+
parts = _attribute_parts(expression)
|
|
265
|
+
if parts is None or len(parts) < _QUALIFIED_NAME_PARTS:
|
|
266
|
+
return False
|
|
267
|
+
if parts[0] in shadowed:
|
|
268
|
+
return False
|
|
269
|
+
resolved = _resolve_expression(expression, self._imports)
|
|
270
|
+
if resolved is None:
|
|
271
|
+
if self._module is None:
|
|
272
|
+
return False
|
|
273
|
+
resolved = (*self._module.split("."), *parts)
|
|
274
|
+
for symbol_index in range(len(resolved) - 2, 0, -1):
|
|
275
|
+
module = ".".join(resolved[:symbol_index])
|
|
276
|
+
symbol = resolved[symbol_index]
|
|
277
|
+
if self._is_settings_symbol(module, symbol):
|
|
278
|
+
return True
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
def _is_settings_class(
|
|
282
|
+
self,
|
|
283
|
+
module: str,
|
|
284
|
+
symbol: str,
|
|
285
|
+
seen: frozenset[tuple[str, str]] = frozenset(),
|
|
286
|
+
) -> bool:
|
|
287
|
+
key = (module, symbol)
|
|
288
|
+
cached = self._settings_class_cache.get(key)
|
|
289
|
+
if cached is not None:
|
|
290
|
+
return cached
|
|
291
|
+
if key in seen:
|
|
292
|
+
return False
|
|
293
|
+
loaded = self._load_module(module)
|
|
294
|
+
if loaded is None:
|
|
295
|
+
self._settings_class_cache[key] = False
|
|
296
|
+
return False
|
|
297
|
+
tree, module_path = loaded
|
|
298
|
+
imports = _imports(tree, module, is_package=module_path.name == "__init__.py")
|
|
299
|
+
if symbol in _base_settings_classes(tree, imports):
|
|
300
|
+
self._settings_class_cache[key] = True
|
|
301
|
+
return True
|
|
302
|
+
class_node = next(
|
|
303
|
+
(node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == symbol),
|
|
304
|
+
None,
|
|
305
|
+
)
|
|
306
|
+
result = False
|
|
307
|
+
if class_node is not None:
|
|
308
|
+
for base in class_node.bases:
|
|
309
|
+
resolved = _resolve_expression(base, imports)
|
|
310
|
+
if resolved is None or len(resolved) < _QUALIFIED_NAME_PARTS:
|
|
311
|
+
continue
|
|
312
|
+
base_module, base_symbol = ".".join(resolved[:-1]), resolved[-1]
|
|
313
|
+
if self._is_settings_class(base_module, base_symbol, seen | {key}):
|
|
314
|
+
result = True
|
|
315
|
+
break
|
|
316
|
+
self._settings_class_cache[key] = result
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
def _is_settings_symbol(self, module: str, symbol: str, seen: frozenset[tuple[str, str]] = frozenset()) -> bool:
|
|
320
|
+
key = (module, symbol)
|
|
321
|
+
cached = self._settings_cache.get(key)
|
|
322
|
+
if cached is not None:
|
|
323
|
+
return cached
|
|
324
|
+
if key in seen:
|
|
325
|
+
return False
|
|
326
|
+
loaded = self._load_module(module)
|
|
327
|
+
if loaded is None:
|
|
328
|
+
self._settings_cache[key] = False
|
|
329
|
+
return False
|
|
330
|
+
tree, module_path = loaded
|
|
331
|
+
imports = _imports(tree, module, is_package=module_path.name == "__init__.py")
|
|
332
|
+
classes = _base_settings_classes(tree, imports)
|
|
333
|
+
factory = _assigned_factory(tree, symbol)
|
|
334
|
+
if factory is not None:
|
|
335
|
+
resolved_factory = _resolve_expression(factory, imports)
|
|
336
|
+
if isinstance(factory, ast.Name) and factory.id in classes:
|
|
337
|
+
self._settings_cache[key] = True
|
|
338
|
+
return True
|
|
339
|
+
if resolved_factory is not None and len(resolved_factory) >= _QUALIFIED_NAME_PARTS:
|
|
340
|
+
factory_module = ".".join(resolved_factory[:-1])
|
|
341
|
+
if self._is_settings_class(factory_module, resolved_factory[-1]):
|
|
342
|
+
self._settings_cache[key] = True
|
|
343
|
+
return True
|
|
344
|
+
binding = imports.get(symbol)
|
|
345
|
+
result = (
|
|
346
|
+
binding is not None
|
|
347
|
+
and binding.symbol is not None
|
|
348
|
+
and self._is_settings_symbol(binding.module, binding.symbol, seen | {key})
|
|
349
|
+
)
|
|
350
|
+
del module_path
|
|
351
|
+
self._settings_cache[key] = result
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
def _load_module(self, module: str) -> tuple[ast.Module, Path] | None:
|
|
355
|
+
if module == self._module:
|
|
356
|
+
return self._tree, self._path
|
|
357
|
+
path = _module_path(module, self._root)
|
|
358
|
+
if path is None:
|
|
359
|
+
return None
|
|
360
|
+
return _read_module(path), path
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _imports(tree: ast.Module, current_module: str | None, *, is_package: bool = False) -> dict[str, _Binding]:
|
|
364
|
+
bindings: dict[str, _Binding] = {}
|
|
365
|
+
for statement in tree.body:
|
|
366
|
+
if isinstance(statement, ast.Import):
|
|
367
|
+
for alias in statement.names:
|
|
368
|
+
bound = alias.asname or alias.name.partition(".")[0]
|
|
369
|
+
module = alias.name if alias.asname else alias.name.partition(".")[0]
|
|
370
|
+
bindings[bound] = _Binding(module, None)
|
|
371
|
+
elif isinstance(statement, ast.ImportFrom):
|
|
372
|
+
module = _absolute_module(statement, current_module, is_package=is_package)
|
|
373
|
+
if module is None:
|
|
374
|
+
continue
|
|
375
|
+
for alias in statement.names:
|
|
376
|
+
if alias.name != "*":
|
|
377
|
+
bindings[alias.asname or alias.name] = _Binding(module, alias.name)
|
|
378
|
+
else:
|
|
379
|
+
for name in _directly_bound_names(statement):
|
|
380
|
+
bindings.pop(name, None)
|
|
381
|
+
return bindings
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _absolute_module(node: ast.ImportFrom, current_module: str | None, *, is_package: bool) -> str | None:
|
|
385
|
+
if node.level == 0:
|
|
386
|
+
return node.module
|
|
387
|
+
if current_module is None:
|
|
388
|
+
return None
|
|
389
|
+
package = current_module.split(".") if is_package else current_module.split(".")[:-1]
|
|
390
|
+
keep = len(package) - (node.level - 1)
|
|
391
|
+
if keep < 0:
|
|
392
|
+
return None
|
|
393
|
+
suffix = node.module.split(".") if node.module else []
|
|
394
|
+
return ".".join([*package[:keep], *suffix])
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _resolve_expression(expression: ast.expr, imports: dict[str, _Binding]) -> tuple[str, ...] | None:
|
|
398
|
+
parts = _attribute_parts(expression)
|
|
399
|
+
if parts is None:
|
|
400
|
+
return None
|
|
401
|
+
binding = imports.get(parts[0])
|
|
402
|
+
if binding is None:
|
|
403
|
+
return None
|
|
404
|
+
prefix = (*binding.module.split("."),) if binding.symbol is None else (*binding.module.split("."), binding.symbol)
|
|
405
|
+
return (*prefix, *parts[1:])
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _attribute_parts(expression: ast.expr) -> tuple[str, ...] | None:
|
|
409
|
+
if isinstance(expression, ast.Name):
|
|
410
|
+
return (expression.id,)
|
|
411
|
+
if isinstance(expression, ast.Attribute):
|
|
412
|
+
parent = _attribute_parts(expression.value)
|
|
413
|
+
return (*parent, expression.attr) if parent is not None else None
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _base_settings_classes(tree: ast.Module, imports: dict[str, _Binding]) -> set[str]:
|
|
418
|
+
classes = [statement for statement in tree.body if isinstance(statement, ast.ClassDef)]
|
|
419
|
+
found = {
|
|
420
|
+
node.name
|
|
421
|
+
for node in classes
|
|
422
|
+
if any(_resolve_expression(base, imports) == ("pydantic_settings", "BaseSettings") for base in node.bases)
|
|
423
|
+
}
|
|
424
|
+
changed = True
|
|
425
|
+
while changed:
|
|
426
|
+
changed = False
|
|
427
|
+
for node in classes:
|
|
428
|
+
if node.name not in found and any(isinstance(base, ast.Name) and base.id in found for base in node.bases):
|
|
429
|
+
found.add(node.name)
|
|
430
|
+
changed = True
|
|
431
|
+
return found
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _assigned_factory(tree: ast.Module, symbol: str) -> ast.expr | None:
|
|
435
|
+
for statement in tree.body:
|
|
436
|
+
if isinstance(statement, ast.Assign):
|
|
437
|
+
targets = statement.targets
|
|
438
|
+
value = statement.value
|
|
439
|
+
elif isinstance(statement, ast.AnnAssign):
|
|
440
|
+
targets = [statement.target]
|
|
441
|
+
value = statement.value
|
|
442
|
+
else:
|
|
443
|
+
continue
|
|
444
|
+
if value is not None and isinstance(value, ast.Call) and any(_is_name(target, symbol) for target in targets):
|
|
445
|
+
return value.func
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _module_name(path: Path, root: Path | None) -> str | None:
|
|
450
|
+
if root is None:
|
|
451
|
+
return None
|
|
452
|
+
resolved_path = path.resolve()
|
|
453
|
+
resolved_root = root.resolve()
|
|
454
|
+
source_root = resolved_root / "src"
|
|
455
|
+
try:
|
|
456
|
+
relative = resolved_path.relative_to(source_root)
|
|
457
|
+
is_package_root = False
|
|
458
|
+
except ValueError:
|
|
459
|
+
try:
|
|
460
|
+
relative = resolved_path.relative_to(resolved_root)
|
|
461
|
+
except ValueError:
|
|
462
|
+
return None
|
|
463
|
+
is_package_root = (resolved_root / "__init__.py").is_file()
|
|
464
|
+
except OSError:
|
|
465
|
+
return None
|
|
466
|
+
parts = list(relative.with_suffix("").parts)
|
|
467
|
+
if parts and parts[-1] == "__init__":
|
|
468
|
+
parts.pop()
|
|
469
|
+
if is_package_root:
|
|
470
|
+
parts.insert(0, root.name)
|
|
471
|
+
return ".".join(parts) if parts else None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _module_path(module: str, root: Path | None) -> Path | None:
|
|
475
|
+
if root is None:
|
|
476
|
+
return None
|
|
477
|
+
parts = module.split(".")
|
|
478
|
+
if (root / "__init__.py").is_file() and parts and parts[0] == root.name:
|
|
479
|
+
parts = parts[1:]
|
|
480
|
+
roots = (root, root / "src")
|
|
481
|
+
relatives = (source_root.joinpath(*parts) for source_root in roots)
|
|
482
|
+
candidates = (
|
|
483
|
+
candidate for relative in relatives for candidate in (relative.with_suffix(".py"), relative / "__init__.py")
|
|
484
|
+
)
|
|
485
|
+
return next((candidate for candidate in candidates if candidate.is_file()), None)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@lru_cache(maxsize=4096)
|
|
489
|
+
def _read_module(path: Path) -> ast.Module:
|
|
490
|
+
try:
|
|
491
|
+
return ast.parse(path.read_text(encoding="utf-8", errors="replace"), filename=str(path))
|
|
492
|
+
except OSError, SyntaxError:
|
|
493
|
+
return ast.Module(body=[], type_ignores=[])
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _tail(expression: ast.expr) -> str | None:
|
|
497
|
+
parts = _attribute_parts(expression)
|
|
498
|
+
return parts[-1] if parts else None
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _has_composition_call(path: Path, class_name: str) -> bool:
|
|
502
|
+
root = distribution_root(path)
|
|
503
|
+
module = _module_name(path, root)
|
|
504
|
+
if root is None or module is None:
|
|
505
|
+
return False
|
|
506
|
+
return _distribution_calls_class(root, module, class_name)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@lru_cache(maxsize=1024)
|
|
510
|
+
def _distribution_calls_class(root: Path, target_module: str, class_name: str) -> bool:
|
|
511
|
+
skip_directories = _SCAN_SKIP_PARTS | _MIGRATION_PARTS | {"test", "tests"}
|
|
512
|
+
for directory, directory_names, file_names in os.walk(root):
|
|
513
|
+
directory_names[:] = [name for name in directory_names if name.lower() not in skip_directories]
|
|
514
|
+
for file_name in file_names:
|
|
515
|
+
if not file_name.endswith(".py"):
|
|
516
|
+
continue
|
|
517
|
+
candidate = Path(directory) / file_name
|
|
518
|
+
if is_test_path(candidate):
|
|
519
|
+
continue
|
|
520
|
+
try:
|
|
521
|
+
source = candidate.read_text(encoding="utf-8", errors="replace")
|
|
522
|
+
except OSError:
|
|
523
|
+
continue
|
|
524
|
+
if class_name not in source or is_generated(candidate, source):
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
tree = ast.parse(source, filename=str(candidate))
|
|
528
|
+
except SyntaxError:
|
|
529
|
+
continue
|
|
530
|
+
module = _module_name(candidate, root)
|
|
531
|
+
if module is None:
|
|
532
|
+
continue
|
|
533
|
+
imports = _imports(tree, module, is_package=candidate.name == "__init__.py")
|
|
534
|
+
for node, shadowed in _calls_with_shadowing(tree):
|
|
535
|
+
parts = _attribute_parts(node.func)
|
|
536
|
+
if parts is not None and parts[0] in shadowed:
|
|
537
|
+
continue
|
|
538
|
+
resolved = _resolve_expression(node.func, imports)
|
|
539
|
+
if resolved is None and isinstance(node.func, ast.Name):
|
|
540
|
+
called_module, called_symbol = module, node.func.id
|
|
541
|
+
elif resolved is not None and len(resolved) >= _QUALIFIED_NAME_PARTS:
|
|
542
|
+
called_module, called_symbol = ".".join(resolved[:-1]), resolved[-1]
|
|
543
|
+
else:
|
|
544
|
+
continue
|
|
545
|
+
if called_symbol != class_name:
|
|
546
|
+
continue
|
|
547
|
+
canonical = _canonical_symbol(root, called_module, called_symbol)
|
|
548
|
+
if canonical == (target_module, class_name):
|
|
549
|
+
return True
|
|
550
|
+
return False
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@lru_cache(maxsize=4096)
|
|
554
|
+
def _canonical_symbol(root: Path, module: str, symbol: str) -> tuple[str, str]:
|
|
555
|
+
return _canonical_symbol_inner(root, module, symbol, frozenset())
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _canonical_symbol_inner(
|
|
559
|
+
root: Path,
|
|
560
|
+
module: str,
|
|
561
|
+
symbol: str,
|
|
562
|
+
seen: frozenset[tuple[str, str]],
|
|
563
|
+
) -> tuple[str, str]:
|
|
564
|
+
key = (module, symbol)
|
|
565
|
+
if key in seen:
|
|
566
|
+
return key
|
|
567
|
+
path = _module_path(module, root)
|
|
568
|
+
if path is None:
|
|
569
|
+
return key
|
|
570
|
+
binding = _imports(_read_module(path), module, is_package=path.name == "__init__.py").get(symbol)
|
|
571
|
+
if binding is None or binding.symbol is None:
|
|
572
|
+
return key
|
|
573
|
+
return _canonical_symbol_inner(root, binding.module, binding.symbol, seen | {key})
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _calls_with_shadowing(
|
|
577
|
+
node: ast.AST,
|
|
578
|
+
shadowed: frozenset[str] = frozenset(),
|
|
579
|
+
nested_scope_base: frozenset[str] | None = None,
|
|
580
|
+
) -> Iterator[tuple[ast.Call, frozenset[str]]]:
|
|
581
|
+
if isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
|
|
582
|
+
lexical_parent = nested_scope_base if nested_scope_base is not None else shadowed
|
|
583
|
+
comprehension_shadowed = lexical_parent
|
|
584
|
+
for index, generator in enumerate(node.generators):
|
|
585
|
+
iterable_shadowed = shadowed if index == 0 else comprehension_shadowed
|
|
586
|
+
yield from _calls_with_shadowing(generator.iter, iterable_shadowed)
|
|
587
|
+
comprehension_shadowed |= frozenset(_target_names(generator.target))
|
|
588
|
+
for condition in generator.ifs:
|
|
589
|
+
yield from _calls_with_shadowing(condition, comprehension_shadowed)
|
|
590
|
+
values = (node.key, node.value) if isinstance(node, ast.DictComp) else (node.elt,)
|
|
591
|
+
for value in values:
|
|
592
|
+
yield from _calls_with_shadowing(value, comprehension_shadowed)
|
|
593
|
+
return
|
|
594
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)):
|
|
595
|
+
outer_nodes: list[ast.AST]
|
|
596
|
+
if isinstance(node, ast.Lambda):
|
|
597
|
+
outer_nodes = [*node.args.defaults, *(default for default in node.args.kw_defaults if default is not None)]
|
|
598
|
+
body_nodes = [node.body]
|
|
599
|
+
else:
|
|
600
|
+
outer_nodes = [
|
|
601
|
+
*node.decorator_list,
|
|
602
|
+
*node.args.defaults,
|
|
603
|
+
*(default for default in node.args.kw_defaults if default is not None),
|
|
604
|
+
]
|
|
605
|
+
body_nodes = list(node.body)
|
|
606
|
+
for outer in outer_nodes:
|
|
607
|
+
yield from _calls_with_shadowing(outer, shadowed, nested_scope_base)
|
|
608
|
+
lexical_parent = nested_scope_base if nested_scope_base is not None else shadowed
|
|
609
|
+
local_shadowed = lexical_parent | _scope_bindings(node)
|
|
610
|
+
for body in body_nodes:
|
|
611
|
+
yield from _calls_with_shadowing(body, local_shadowed)
|
|
612
|
+
return
|
|
613
|
+
if isinstance(node, ast.ClassDef):
|
|
614
|
+
outer_nodes = [*node.decorator_list, *node.bases, *node.keywords]
|
|
615
|
+
for outer in outer_nodes:
|
|
616
|
+
yield from _calls_with_shadowing(outer, shadowed, nested_scope_base)
|
|
617
|
+
lexical_parent = nested_scope_base if nested_scope_base is not None else shadowed
|
|
618
|
+
class_shadowed = lexical_parent | _class_bindings(node)
|
|
619
|
+
for body in node.body:
|
|
620
|
+
yield from _calls_with_shadowing(body, class_shadowed, lexical_parent)
|
|
621
|
+
return
|
|
622
|
+
if isinstance(node, ast.Call):
|
|
623
|
+
yield node, shadowed
|
|
624
|
+
for child in ast.iter_child_nodes(node):
|
|
625
|
+
yield from _calls_with_shadowing(child, shadowed, nested_scope_base)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _scope_bindings(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda) -> frozenset[str]:
|
|
629
|
+
collector = _LocalBindingCollector()
|
|
630
|
+
for argument in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs):
|
|
631
|
+
collector.names.add(argument.arg)
|
|
632
|
+
if node.args.vararg is not None:
|
|
633
|
+
collector.names.add(node.args.vararg.arg)
|
|
634
|
+
if node.args.kwarg is not None:
|
|
635
|
+
collector.names.add(node.args.kwarg.arg)
|
|
636
|
+
body = [node.body] if isinstance(node, ast.Lambda) else node.body
|
|
637
|
+
for statement in body:
|
|
638
|
+
collector.visit(statement)
|
|
639
|
+
return frozenset(collector.names - collector.globals)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _class_bindings(node: ast.ClassDef) -> frozenset[str]:
|
|
643
|
+
collector = _LocalBindingCollector()
|
|
644
|
+
for statement in node.body:
|
|
645
|
+
collector.visit(statement)
|
|
646
|
+
return frozenset(collector.names)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class _LocalBindingCollector(ast.NodeVisitor):
|
|
650
|
+
def __init__(self) -> None:
|
|
651
|
+
self.names: set[str] = set()
|
|
652
|
+
self.globals: set[str] = set()
|
|
653
|
+
|
|
654
|
+
@override
|
|
655
|
+
def visit_Name(self, node: ast.Name) -> None:
|
|
656
|
+
if isinstance(node.ctx, ast.Store):
|
|
657
|
+
self.names.add(node.id)
|
|
658
|
+
|
|
659
|
+
@override
|
|
660
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
661
|
+
self.names.update(alias.asname or alias.name.partition(".")[0] for alias in node.names)
|
|
662
|
+
|
|
663
|
+
@override
|
|
664
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
665
|
+
self.names.update(alias.asname or alias.name for alias in node.names if alias.name != "*")
|
|
666
|
+
|
|
667
|
+
@override
|
|
668
|
+
def visit_Global(self, node: ast.Global) -> None:
|
|
669
|
+
self.globals.update(node.names)
|
|
670
|
+
|
|
671
|
+
@override
|
|
672
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
673
|
+
self.names.add(node.name)
|
|
674
|
+
|
|
675
|
+
@override
|
|
676
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
677
|
+
self.names.add(node.name)
|
|
678
|
+
|
|
679
|
+
@override
|
|
680
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
681
|
+
self.names.add(node.name)
|
|
682
|
+
|
|
683
|
+
@override
|
|
684
|
+
def visit_Lambda(self, node: ast.Lambda) -> None:
|
|
685
|
+
del node
|
|
686
|
+
|
|
687
|
+
@override
|
|
688
|
+
def visit_ListComp(self, node: ast.ListComp) -> None:
|
|
689
|
+
del node
|
|
690
|
+
|
|
691
|
+
@override
|
|
692
|
+
def visit_SetComp(self, node: ast.SetComp) -> None:
|
|
693
|
+
del node
|
|
694
|
+
|
|
695
|
+
@override
|
|
696
|
+
def visit_DictComp(self, node: ast.DictComp) -> None:
|
|
697
|
+
del node
|
|
698
|
+
|
|
699
|
+
@override
|
|
700
|
+
def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None:
|
|
701
|
+
del node
|
|
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.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_ast_index.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_docstrings.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_first_party.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_prose_budget.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
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/mock_without_spec.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_comment_cruft.py
RENAMED
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fstring_in_log.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.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
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_secret_in_log.py
RENAMED
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_select_star.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_stdlib_logging.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/over_mocked_test.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.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
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.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.44.0 → sarj_python_lint-0.45.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
|
{sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/unused_mock_setup.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|