sarj-python-lint 0.67.2__tar.gz → 0.67.4__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 (99) hide show
  1. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_fastapi.py +36 -1
  4. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_immutable_module_constant.py +9 -3
  5. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_module_level_constant.py +10 -2
  6. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +144 -1
  7. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/.gitignore +0 -0
  8. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/LICENSE +0 -0
  9. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/README.md +0 -0
  10. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/__init__.py +0 -0
  11. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/__main__.py +0 -0
  12. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/_filesystem.py +0 -0
  13. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  14. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/_secret_names.py +0 -0
  15. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/_version.py +0 -0
  16. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/py.typed +0 -0
  17. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/ratchet.py +0 -0
  18. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rule_base.py +0 -0
  19. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/__init__.py +0 -0
  20. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  21. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_comments.py +0 -0
  22. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  23. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_first_party.py +0 -0
  24. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_imports.py +0 -0
  25. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_logging.py +0 -0
  26. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_paths.py +0 -0
  27. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  28. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_pytest.py +0 -0
  29. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_registry.py +0 -0
  30. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_sql.py +0 -0
  31. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  32. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/_test_assertions.py +0 -0
  33. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/defect_xfail_requires_strict.py +0 -0
  34. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  35. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  36. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  37. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  38. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/fastapi_openapi_contract.py +0 -0
  39. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  40. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/invalid_pydantic_field_default.py +0 -0
  41. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  42. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  43. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  44. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  45. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  46. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_duplicate_dunder_all_entry.py +0 -0
  47. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  48. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  49. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  50. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_frozen_after_validator_field_write.py +0 -0
  51. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  52. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_generic_single_export_module.py +0 -0
  53. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_hidden_constructor_fallback.py +0 -0
  54. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  55. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  56. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  57. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  58. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  59. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  60. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  61. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  62. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  63. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  64. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  65. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_string_concat_in_loop.py +0 -0
  66. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  67. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  68. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/opaque_parametrize_case_needs_id.py +0 -0
  69. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  70. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  71. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  72. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  73. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  74. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  75. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  76. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  77. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  78. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_nominal_id_types.py +0 -0
  79. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  80. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  81. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_self_documenting_constant.py +0 -0
  82. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  83. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  84. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  85. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  86. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  87. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  88. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  89. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  90. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  91. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/redundant_module_docstring.py +0 -0
  92. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/require_keyword_only_swap_prone_params.py +0 -0
  93. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  94. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  95. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/stepdown.py +0 -0
  96. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  97. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  98. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  99. {sarj_python_lint-0.67.2 → sarj_python_lint-0.67.4}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sarj-python-lint
3
- Version: 0.67.2
3
+ Version: 0.67.4
4
4
  Summary: Custom Python lint rules — AST-based, pre-commit-friendly, hypermodern defaults
5
5
  Project-URL: Homepage, https://code-standards.sarj.ai/rules/python/
6
6
  Project-URL: Documentation, https://code-standards.sarj.ai/rules/python/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.67.2"
3
+ version = "0.67.4"
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" }]
@@ -570,7 +570,7 @@ def _resolve_module_path(current: Path, reference: _ImportedReference) -> Path |
570
570
  current = current.resolve()
571
571
  checkout = _checkout_root(current)
572
572
  if checkout is None:
573
- return None
573
+ return _resolve_module_path_without_checkout(current, reference)
574
574
  module_parts = tuple(part for part in reference.module.split(".") if part)
575
575
  candidates: set[Path] = set()
576
576
  if 0 < reference.level <= _MAX_PATH_ANCESTORS:
@@ -593,6 +593,41 @@ def _resolve_module_path(current: Path, reference: _ImportedReference) -> Path |
593
593
  return next(iter(candidates)) if len(candidates) == 1 else None
594
594
 
595
595
 
596
+ def _resolve_module_path_without_checkout(current: Path, reference: _ImportedReference) -> Path | None:
597
+ """Resolve an explicitly local import when a detached source tree has no VCS root.
598
+
599
+ Relative imports must stay inside a real package chain. Absolute imports are
600
+ considered only at an ancestor matching their first component, or as a
601
+ direct sibling module. These exact, bounded anchors avoid treating an
602
+ arbitrary filesystem ancestor as an import search path.
603
+ """
604
+ module_parts = tuple(part for part in reference.module.split(".") if part)
605
+ candidates: set[Path] = set()
606
+ if 0 < reference.level <= _MAX_PATH_ANCESTORS:
607
+ package = current.parent
608
+ if not (package / "__init__.py").is_file():
609
+ return None
610
+ for _ in range(reference.level - 1):
611
+ parent = package.parent
612
+ if not (parent / "__init__.py").is_file():
613
+ return None
614
+ package = parent
615
+ candidates.update(_existing_module_files(package.joinpath(*module_parts), package))
616
+ elif module_parts:
617
+ first = module_parts[0]
618
+ for depth, ancestor in enumerate(current.parent.parents):
619
+ if depth >= _MAX_PATH_ANCESTORS:
620
+ break
621
+ package = ancestor / first
622
+ if package != current.parent and not current.is_relative_to(package):
623
+ continue
624
+ candidates.update(_existing_module_files(ancestor.joinpath(*module_parts), ancestor))
625
+ # A detached directory of modules is also a valid, deliberately narrow
626
+ # import root. Do not search any of its parents for an unrelated module.
627
+ candidates.update(_existing_module_files(current.parent.joinpath(*module_parts), current.parent))
628
+ return next(iter(candidates)) if len(candidates) == 1 else None
629
+
630
+
596
631
  def _checkout_root(current: Path) -> Path | None:
597
632
  for depth, ancestor in enumerate(current.parents):
598
633
  if depth >= _MAX_PATH_ANCESTORS:
@@ -202,6 +202,7 @@ class _MutationVisitor(ast.NodeVisitor):
202
202
  self.mutated: set[str] = mutated
203
203
  self._scopes: list[tuple[set[str], set[str]]] = []
204
204
  self._local_container_roots: list[dict[str, set[str]]] = []
205
+ self._module_container_roots: dict[str, set[str]] = {}
205
206
 
206
207
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
207
208
  self._visit_function(node)
@@ -278,11 +279,14 @@ class _MutationVisitor(ast.NodeVisitor):
278
279
  self.generic_visit(node)
279
280
 
280
281
  def _remember_local_container_roots(self, targets: tuple[ast.expr, ...] | list[ast.expr], value: ast.expr) -> None:
281
- if not self._local_container_roots:
282
- return
283
282
  roots = self._module_roots(value)
284
283
  if not roots:
285
284
  return
285
+ if not self._local_container_roots:
286
+ for target in targets:
287
+ if isinstance(target, ast.Name):
288
+ self._module_container_roots.setdefault(target.id, set()).update(roots)
289
+ return
286
290
  locals_ = self._scopes[-1][0]
287
291
  aliases = self._local_container_roots[-1]
288
292
  for target in targets:
@@ -298,9 +302,11 @@ class _MutationVisitor(ast.NodeVisitor):
298
302
  (scope[name] for scope in reversed(self._local_container_roots) if name in scope),
299
303
  None,
300
304
  )
305
+ if alias is None:
306
+ alias = self._module_container_roots.get(name)
301
307
  if alias is not None:
302
308
  roots.update(alias)
303
- elif self._is_module_reference(name):
309
+ if self._is_module_reference(name):
304
310
  roots.add(name)
305
311
  return roots
306
312
 
@@ -432,12 +432,20 @@ def _is_safe_read(node: ast.Name, parents: dict[int, ast.AST], safe_methods: fro
432
432
  return _is_safe_method_call(parent, parents, safe_methods)
433
433
  case ast.Subscript(value=value, ctx=ctx):
434
434
  # `x[k]` reads; `x[k] = v` / `del x[k]` mutate.
435
- return isinstance(ctx, ast.Load) if value is node else True
435
+ # A binding used as `frame[x]` is not representation-neutral:
436
+ # pandas and other selector APIs distinguish a list from a tuple.
437
+ return value is node and isinstance(ctx, ast.Load)
436
438
  case ast.Call(args=args, func=callee):
437
439
  return any(arg is node for arg in args) and _is_safe_callee(callee)
438
440
  case ast.keyword(arg=str(), value=value) if value is node:
439
441
  grandparent = parents.get(id(parent))
440
- return isinstance(grandparent, ast.Call) and _is_safe_callee(grandparent.func)
442
+ # `dict(label=x)` retains `x` as a nested value; unlike
443
+ # `dict(x)`, it does not copy the candidate collection itself.
444
+ return (
445
+ isinstance(grandparent, ast.Call)
446
+ and _is_safe_callee(grandparent.func)
447
+ and _dotted_name(grandparent.func) != "dict"
448
+ )
441
449
  case ast.Compare():
442
450
  return True
443
451
  case ast.For() | ast.AsyncFor() | ast.comprehension():
@@ -143,6 +143,7 @@ class PreferTimedeltaForDurations(Rule):
143
143
  exempt_fields = (
144
144
  _settings_field_ids(tree) | _pydantic_model_field_ids(tree, imports) | _typed_dict_field_ids(tree, imports)
145
145
  )
146
+ serialized_constants = _exclusively_serialized_duration_constants(tree, imports)
146
147
  diags: list[Diagnostic] = []
147
148
  for node in nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef, ast.AnnAssign):
148
149
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -163,7 +164,7 @@ class PreferTimedeltaForDurations(Rule):
163
164
  for statement in tree.body:
164
165
  match statement:
165
166
  case ast.Assign(targets=[ast.Name(id=name)], value=value) if _is_numeric_expression(value):
166
- if _is_constant_reference(name):
167
+ if _is_constant_reference(name) and name not in serialized_constants:
167
168
  numeric = "float" if _contains_float(value) else "int"
168
169
  self._consider(name, ast.Name(id=numeric), statement, diags, path)
169
170
  case _:
@@ -238,6 +239,148 @@ def _contains_float(node: ast.AST) -> bool:
238
239
  return any(isinstance(child, ast.Constant) and isinstance(child.value, float) for child in ast.walk(node))
239
240
 
240
241
 
242
+ def _exclusively_serialized_duration_constants(tree: ast.Module, imports: ImportIndex) -> frozenset[str]:
243
+ """Find numeric constants whose reads are proven wire-model construction only."""
244
+ definitions = {
245
+ statement.targets[0].id: statement.targets[0]
246
+ for statement in tree.body
247
+ if isinstance(statement, ast.Assign)
248
+ and len(statement.targets) == 1
249
+ and isinstance(statement.targets[0], ast.Name)
250
+ and _is_numeric_expression(statement.value)
251
+ and _is_constant_reference(statement.targets[0].id)
252
+ }
253
+ if not definitions:
254
+ return frozenset()
255
+ wire_models = _wire_model_class_names(tree, imports)
256
+ if not wire_models:
257
+ return frozenset()
258
+ parent = {id(child): owner for owner in ast.walk(tree) for child in ast.iter_child_nodes(owner)}
259
+ serialized: set[str] = set()
260
+ for name, target in definitions.items():
261
+ if _has_competing_binding(tree, name, target):
262
+ continue
263
+ loads = [
264
+ node
265
+ for node in ast.walk(tree)
266
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id == name
267
+ ]
268
+ if loads and all(_is_wire_model_value(load, parent, wire_models) for load in loads):
269
+ serialized.add(name)
270
+ return frozenset(serialized)
271
+
272
+
273
+ def _has_competing_binding(tree: ast.Module, name: str, definition: ast.Name) -> bool:
274
+ """Reject lexical ambiguity rather than attributing shadowed reads to a constant."""
275
+ for node in ast.walk(tree):
276
+ match node:
277
+ case ast.Name(id=identifier, ctx=ast.Store()) if identifier == name and node is not definition:
278
+ return True
279
+ case ast.arg(arg=identifier) if identifier == name:
280
+ return True
281
+ case (
282
+ ast.FunctionDef(name=identifier) | ast.AsyncFunctionDef(name=identifier) | ast.ClassDef(name=identifier)
283
+ ) if identifier == name:
284
+ return True
285
+ case ast.alias(name=imported, asname=alias) if (alias or imported.split(".")[0]) == name:
286
+ return True
287
+ case ast.ExceptHandler(name=identifier) if identifier == name:
288
+ return True
289
+ case ast.MatchAs(name=identifier) | ast.MatchStar(name=identifier) if identifier == name:
290
+ return True
291
+ case ast.MatchMapping(rest=identifier) if identifier == name:
292
+ return True
293
+ case _:
294
+ pass
295
+ return False
296
+
297
+
298
+ def _is_wire_model_value(
299
+ load: ast.Name,
300
+ parents: dict[int, ast.AST],
301
+ wire_models: frozenset[str],
302
+ ) -> bool:
303
+ parent = parents.get(id(load))
304
+ if isinstance(parent, ast.keyword) and parent.value is load:
305
+ call = parents.get(id(parent))
306
+ return isinstance(call, ast.Call) and _trailing_name(call.func) in wire_models
307
+ if not (isinstance(parent, ast.Dict) and any(value is load for value in parent.values)):
308
+ return False
309
+ returned = parents.get(id(parent))
310
+ if not (isinstance(returned, ast.Return) and returned.value is parent):
311
+ return False
312
+ owner = parents.get(id(returned))
313
+ while owner is not None and not isinstance(owner, (ast.FunctionDef, ast.AsyncFunctionDef)):
314
+ owner = parents.get(id(owner))
315
+ return owner is not None and bool(_annotation_names(owner.returns) & wire_models)
316
+
317
+
318
+ def _annotation_names(node: ast.expr | None) -> frozenset[str]:
319
+ if node is None:
320
+ return frozenset()
321
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
322
+ try:
323
+ node = ast.parse(node.value.strip(), mode="eval").body
324
+ except SyntaxError:
325
+ return frozenset()
326
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr):
327
+ return _annotation_names(node.left) | _annotation_names(node.right)
328
+ if isinstance(node, ast.Subscript) and _trailing_name(node.value) in {"Annotated", "Optional"}:
329
+ inner = node.slice.elts[0] if isinstance(node.slice, ast.Tuple) and node.slice.elts else node.slice
330
+ return _annotation_names(inner)
331
+ name = _trailing_name(node)
332
+ return frozenset({name}) if name is not None else frozenset()
333
+
334
+
335
+ def _wire_model_class_names(tree: ast.Module, imports: ImportIndex) -> frozenset[str]:
336
+ """Resolve locally declared Pydantic and TypedDict wire models."""
337
+ classes = nodes(tree, ast.ClassDef)
338
+ by_name: dict[str, list[ast.ClassDef]] = {}
339
+ for node in classes:
340
+ by_name.setdefault(node.name, []).append(node)
341
+ resolved: dict[int, bool] = {}
342
+
343
+ def is_wire_model(node: ast.ClassDef, seen: frozenset[int]) -> bool:
344
+ cached = resolved.get(id(node))
345
+ if cached is not None:
346
+ return cached
347
+ if id(node) in seen:
348
+ return False
349
+ result = any(
350
+ imports.resolves(
351
+ base,
352
+ sources=frozenset(
353
+ {
354
+ "pydantic",
355
+ "pydantic.main",
356
+ "pydantic.v1",
357
+ "pydantic.v1.main",
358
+ }
359
+ ),
360
+ symbol="BaseModel",
361
+ )
362
+ or imports.resolves(
363
+ base,
364
+ sources=frozenset({"typing", "typing_extensions"}),
365
+ symbol="TypedDict",
366
+ )
367
+ or (
368
+ (base_name := _trailing_name(base)) is not None
369
+ and len(parents := by_name.get(base_name, ())) == 1
370
+ and is_wire_model(parents[0], seen | {id(node)})
371
+ )
372
+ for base in node.bases
373
+ )
374
+ resolved[id(node)] = result
375
+ return result
376
+
377
+ return frozenset(
378
+ name
379
+ for name, definitions in by_name.items()
380
+ if len(definitions) == 1 and is_wire_model(definitions[0], frozenset())
381
+ )
382
+
383
+
241
384
  def _has_cli_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
242
385
  """Report whether the function is a `click` / `typer` command entry point."""
243
386
  for dec in node.decorator_list: