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.
Files changed (103) hide show
  1. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/PKG-INFO +26 -2
  2. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/README.md +25 -1
  3. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/pyproject.toml +1 -1
  4. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_registry.py +4 -0
  5. sarj_python_lint-0.45.0/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +701 -0
  6. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/.gitignore +0 -0
  7. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  10. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_secret_names.py +0 -0
  11. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/_version.py +0 -0
  12. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/py.typed +0 -0
  13. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/ratchet.py +0 -0
  14. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rule_base.py +0 -0
  15. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  16. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  17. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  18. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  19. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_fastapi.py +0 -0
  20. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  21. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  22. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  23. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  24. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  25. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  26. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  27. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  28. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  29. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  30. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  31. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  32. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  33. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  34. {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
  35. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  36. {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
  37. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  38. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  39. {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
  40. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  41. {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
  42. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  43. {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
  44. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  45. {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
  46. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  47. {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
  48. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  49. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  50. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  51. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  52. {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
  53. {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
  54. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  55. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  56. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  57. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  58. {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
  59. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  60. {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
  61. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  62. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  63. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  64. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  65. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  66. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  67. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  68. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  69. {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
  70. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  71. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  72. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  73. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  74. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  75. {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
  76. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  77. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  78. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  79. {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
  80. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  81. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -0
  82. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  83. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  84. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  85. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  86. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  87. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  88. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  89. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  90. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  91. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  92. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  93. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  94. {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
  95. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  96. {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
  97. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  98. {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
  99. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  100. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  101. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  102. {sarj_python_lint-0.44.0 → sarj_python_lint-0.45.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  103. {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.44.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.44.0
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.44.0
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.44.0"
3
+ version = "0.45.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" }]
@@ -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