sarj-python-lint 0.42.0__tar.gz → 0.43.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 (101) hide show
  1. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/PKG-INFO +1 -1
  2. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/pyproject.toml +1 -1
  3. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_registry.py +2 -0
  4. sarj_python_lint-0.43.0/src/sarj_python_lint/rules/prefer_nominal_id_types.py +219 -0
  5. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/.gitignore +0 -0
  6. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/README.md +0 -0
  7. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/__init__.py +0 -0
  8. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/__main__.py +0 -0
  9. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/_ratchet_cli.py +0 -0
  10. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/_secret_names.py +0 -0
  11. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/_version.py +0 -0
  12. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/py.typed +0 -0
  13. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/ratchet.py +0 -0
  14. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rule_base.py +0 -0
  15. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/__init__.py +0 -0
  16. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_ast_index.py +0 -0
  17. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_comments.py +0 -0
  18. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_docstrings.py +0 -0
  19. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_first_party.py +0 -0
  20. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_logging.py +0 -0
  21. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_paths.py +0 -0
  22. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_prose_budget.py +0 -0
  23. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_pytest.py +0 -0
  24. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_sql.py +0 -0
  25. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/_suppression_comments.py +0 -0
  26. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/conditional_assertion_in_test.py +0 -0
  27. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/docstring_args_restate_signature.py +0 -0
  28. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/docstring_returns_restate_signature.py +0 -0
  29. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/duplicate_test_body.py +0 -0
  30. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/duplicated_override_docstring.py +0 -0
  31. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/fixture_returns_bare_tuple.py +0 -0
  32. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/inefficient_string_concat_in_loop.py +0 -0
  33. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/interaction_only_test.py +0 -0
  34. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/kwarg_heavy_construction_in_test.py +0 -0
  35. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/kwonly_same_type_params.py +0 -0
  36. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/mock_without_spec.py +0 -0
  37. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_aggregation_in_store_query.py +0 -0
  38. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_comment_cruft.py +0 -0
  39. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_cors_wildcard_with_credentials.py +0 -0
  40. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_fat_try_blocks.py +0 -0
  41. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_file_level_escape_hatch_noqa.py +0 -0
  42. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_file_level_suppression.py +0 -0
  43. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_first_party_private_import.py +0 -0
  44. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_fstring_in_log.py +0 -0
  45. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_gen_random_uuid_in_sql.py +0 -0
  46. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_isinstance_union_chain.py +0 -0
  47. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_long_comment.py +0 -0
  48. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_offset_pagination.py +0 -0
  49. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_optional_tenant_predicate.py +0 -0
  50. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_query_with_many_joins.py +0 -0
  51. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_raw_sql_in_tests.py +0 -0
  52. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_repeated_string_literal.py +0 -0
  53. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_restated_comment.py +0 -0
  54. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_secret_in_log.py +0 -0
  55. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_select_star.py +0 -0
  56. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_sentinel_return_on_except.py +0 -0
  57. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_sequential_await.py +0 -0
  58. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_sleep_in_test_body.py +0 -0
  59. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_stdlib_logging.py +0 -0
  60. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_tautological_expect.py +0 -0
  61. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_typed_doc_sections.py +0 -0
  62. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/no_unreachable_after_terminal.py +0 -0
  63. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/over_mocked_test.py +0 -0
  64. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/parametrize_case_needs_id.py +0 -0
  65. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/phase_label_comment.py +0 -0
  66. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_class_row.py +0 -0
  67. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_constant_time_secret_compare.py +0 -0
  68. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_fstring_over_concat.py +0 -0
  69. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_library_fake.py +0 -0
  70. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_match_assert_never.py +0 -0
  71. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_match_pattern_destructuring.py +0 -0
  72. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_match_type_dispatch.py +0 -0
  73. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_module_level_constant.py +0 -0
  74. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_namedtuple_over_tuple_return.py +0 -0
  75. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_non_nullable_collection.py +0 -0
  76. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_or_pattern.py +0 -0
  77. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_real_store_in_tests.py +0 -0
  78. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_self_type_annotation.py +0 -0
  79. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_single_sentence_comment.py +0 -0
  80. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_str_enum.py +0 -0
  81. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_struct_over_namedtuple.py +0 -0
  82. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_timedelta_for_durations.py +0 -0
  83. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_walrus_comprehension_filter.py +0 -0
  84. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_walrus_regex_match.py +0 -0
  85. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/prefer_walrus_stream_loop.py +0 -0
  86. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/pydantic_at_boundaries.py +0 -0
  87. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/redundant_class_docstring.py +0 -0
  88. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/redundant_docstring.py +0 -0
  89. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/require_port_for_service.py +0 -0
  90. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/restated_test_docstring.py +0 -0
  91. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/single_public_export.py +0 -0
  92. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/sleep_with_computed_arg_in_test.py +0 -0
  93. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/stepdown.py +0 -0
  94. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/store_insert_requires_on_conflict.py +0 -0
  95. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/tautological_mock_assertion.py +0 -0
  96. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/test_loops_over_literal_cases.py +0 -0
  97. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/trailing_value_narration.py +0 -0
  98. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/trivially_true_assertion.py +0 -0
  99. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/unused_mock_setup.py +0 -0
  100. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.0}/src/sarj_python_lint/rules/xfail_requires_strict.py +0 -0
  101. {sarj_python_lint-0.42.0 → sarj_python_lint-0.43.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.42.0
3
+ Version: 0.43.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sarj-python-lint"
3
- version = "0.42.0"
3
+ version = "0.43.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" }]
@@ -75,6 +75,7 @@ from sarj_python_lint.rules.prefer_module_level_constant import (
75
75
  from sarj_python_lint.rules.prefer_namedtuple_over_tuple_return import (
76
76
  PreferNamedtupleOverTupleReturn,
77
77
  )
78
+ from sarj_python_lint.rules.prefer_nominal_id_types import PreferNominalIdTypes
78
79
  from sarj_python_lint.rules.prefer_non_nullable_collection import (
79
80
  PreferNonNullableCollection,
80
81
  )
@@ -196,6 +197,7 @@ REGISTRY: dict[str, type[Rule]] = {
196
197
  PreferSingleSentenceComment.id: PreferSingleSentenceComment,
197
198
  NoLongComment.id: NoLongComment,
198
199
  NoTypedDocSections.id: NoTypedDocSections,
200
+ PreferNominalIdTypes.id: PreferNominalIdTypes,
199
201
  }
200
202
 
201
203
  __all__ = ["REGISTRY"]
@@ -0,0 +1,219 @@
1
+ """SARJ093 flags boundaries where multiple ID roles share primitive string types.
2
+
3
+ Examples: https://github.com/sarj-ai/standards/blob/main/packages/python/tests/rules/test_prefer_nominal_id_types.py
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING, override
11
+
12
+ from sarj_python_lint.rule_base import Diagnostic, Rule, is_suppressed, parse_or_none
13
+ from sarj_python_lint.rules._ast_index import walk
14
+ from sarj_python_lint.rules._paths import is_generated, is_test_path
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+
21
+ _TRANSPARENT_WRAPPERS = frozenset({"List", "Mapped", "Optional", "Sequence", "list"})
22
+ _UNION_WRAPPERS = frozenset({"Union"})
23
+ _MIN_ID_ROLES = 2
24
+ _OPERATIONAL_IDS = frozenset(
25
+ {
26
+ "correlation_id",
27
+ "event_id",
28
+ "job_id",
29
+ "message_id",
30
+ "operation_id",
31
+ "request_id",
32
+ "span_id",
33
+ "task_id",
34
+ "trace_id",
35
+ "unique_id",
36
+ }
37
+ )
38
+ _MIGRATION_PARTS = frozenset({"alembic", "migrations", "versions"})
39
+ _NON_PRODUCTION_PARTS = frozenset({"fixtures", "scripts", "test_fakes", "testing"})
40
+ _RAW_SCHEMA_SUFFIXES = ("Config", "Credentials", "Settings")
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class _IdRole:
45
+ name: str
46
+ annotation: ast.expr
47
+ raw_string: bool
48
+
49
+
50
+ class PreferNominalIdTypes(Rule):
51
+ id: str = "prefer-nominal-id-types"
52
+ code: str = "SARJ093"
53
+ description: str = (
54
+ "A production boundary with multiple ID-shaped roles should use nominal ID types, not primitive strings."
55
+ )
56
+
57
+ @override
58
+ def check(self, path: Path, source: str) -> list[Diagnostic]:
59
+ if (
60
+ is_test_path(path)
61
+ or is_generated(path, source)
62
+ or any(part.lower() in _MIGRATION_PARTS for part in path.parts)
63
+ or any(part.lower() in _NON_PRODUCTION_PARTS for part in path.parts)
64
+ or _is_external_adapter_path(path)
65
+ ):
66
+ return []
67
+ tree = parse_or_none(path, source)
68
+ if tree is None:
69
+ return []
70
+
71
+ source_lines = source.splitlines()
72
+ diagnostics: list[Diagnostic] = []
73
+ exempt_schema_nodes = {
74
+ descendant
75
+ for candidate in ast.walk(tree)
76
+ if isinstance(candidate, ast.ClassDef) and _is_raw_schema_class(candidate)
77
+ for descendant in ast.walk(candidate)
78
+ if descendant is not candidate
79
+ }
80
+ for node in walk(tree):
81
+ if node in exempt_schema_nodes:
82
+ continue
83
+ roles = _boundary_roles(node)
84
+ raw = [role for role in roles if role.raw_string]
85
+ if not raw or len({role.name for role in roles}) < _MIN_ID_ROLES:
86
+ continue
87
+ first = raw[0]
88
+ if is_suppressed(source_lines, first.annotation.lineno, self.code):
89
+ continue
90
+ names = ", ".join(f"`{role.name}`" for role in roles)
91
+ diagnostics.append(
92
+ Diagnostic(
93
+ path=path,
94
+ line=first.annotation.lineno,
95
+ col=first.annotation.col_offset + 1,
96
+ code=self.code,
97
+ message=(
98
+ f"{names} are multiple ID-shaped roles at one boundary, but at least one uses primitive `str`; "
99
+ "introduce or reuse `NewType` identifier types and propagate them through the boundary."
100
+ ),
101
+ )
102
+ )
103
+ return sorted(diagnostics, key=lambda diagnostic: (diagnostic.line, diagnostic.col))
104
+
105
+
106
+ def _boundary_roles(node: ast.AST) -> list[_IdRole]:
107
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
108
+ if node.name.startswith("_") and node.name != "__init__":
109
+ return []
110
+ arguments = [*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs]
111
+ return [role for argument in arguments if (role := _role(argument.arg, argument.annotation)) is not None]
112
+ if isinstance(node, ast.ClassDef):
113
+ if _is_raw_schema_class(node):
114
+ return []
115
+ return [
116
+ role
117
+ for statement in node.body
118
+ if isinstance(statement, ast.AnnAssign) and isinstance(statement.target, ast.Name)
119
+ if (role := _role(statement.target.id, statement.annotation, allow_bare_id=True)) is not None
120
+ ]
121
+ return []
122
+
123
+
124
+ def _role(name: str, annotation: ast.expr | None, *, allow_bare_id: bool = False) -> _IdRole | None:
125
+ if annotation is None or name in _OPERATIONAL_IDS:
126
+ return None
127
+ if not (name.endswith(("_id", "_ids")) or (allow_bare_id and name == "id")):
128
+ return None
129
+ raw_string = _is_raw_string_id(annotation)
130
+ if not raw_string and not _is_nominal_id(annotation):
131
+ return None
132
+ return _IdRole(name=name, annotation=annotation, raw_string=raw_string)
133
+
134
+
135
+ def _qualified_tail(node: ast.expr) -> str:
136
+ if isinstance(node, ast.Name):
137
+ return node.id
138
+ if isinstance(node, ast.Attribute):
139
+ return node.attr
140
+ return ""
141
+
142
+
143
+ def _is_raw_schema_class(node: ast.ClassDef) -> bool:
144
+ return node.name.endswith(_RAW_SCHEMA_SUFFIXES) or any(
145
+ _qualified_tail(base) == "Protocol" for base in node.bases
146
+ )
147
+
148
+
149
+ def _is_external_adapter_path(path: Path) -> bool:
150
+ parts = {part.lower() for part in path.parts}
151
+ return "providers" in parts or "integration" in parts or (
152
+ "integrations" in parts and ("models" in parts or path.name == "models.py")
153
+ )
154
+
155
+
156
+ def _is_raw_string_id(annotation: ast.expr) -> bool:
157
+ if isinstance(annotation, ast.Name):
158
+ return annotation.id == "str"
159
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
160
+ members = _flatten_union(annotation)
161
+ return any(_is_raw_string_id(member) for member in members) and all(
162
+ _is_raw_string_id(member) or _is_nominal_id(member) or _is_none(member)
163
+ for member in members
164
+ )
165
+ if not isinstance(annotation, ast.Subscript):
166
+ return False
167
+ wrapper = _qualified_tail(annotation.value)
168
+ if wrapper == "Annotated":
169
+ return (
170
+ isinstance(annotation.slice, ast.Tuple)
171
+ and bool(annotation.slice.elts)
172
+ and _is_raw_string_id(annotation.slice.elts[0])
173
+ )
174
+ if wrapper in _TRANSPARENT_WRAPPERS:
175
+ return _is_raw_string_id(annotation.slice)
176
+ if wrapper in _UNION_WRAPPERS:
177
+ members = list(annotation.slice.elts) if isinstance(annotation.slice, ast.Tuple) else [annotation.slice]
178
+ return any(_is_raw_string_id(member) for member in members) and all(
179
+ _is_raw_string_id(member) or _is_nominal_id(member) or _is_none(member)
180
+ for member in members
181
+ )
182
+ return False
183
+
184
+
185
+ def _flatten_union(annotation: ast.expr) -> list[ast.expr]:
186
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
187
+ return [*_flatten_union(annotation.left), *_flatten_union(annotation.right)]
188
+ return [annotation]
189
+
190
+
191
+ def _is_none(annotation: ast.expr) -> bool:
192
+ return (isinstance(annotation, ast.Constant) and annotation.value is None) or (
193
+ isinstance(annotation, ast.Name) and annotation.id == "None"
194
+ )
195
+
196
+
197
+ def _is_nominal_id(annotation: ast.expr) -> bool:
198
+ if isinstance(annotation, (ast.Name, ast.Attribute)):
199
+ tail = _qualified_tail(annotation)
200
+ return tail.endswith(("Id", "ID"))
201
+ if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
202
+ members = _flatten_union(annotation)
203
+ return any(_is_nominal_id(member) for member in members) and all(
204
+ _is_nominal_id(member) or _is_none(member) for member in members
205
+ )
206
+ if isinstance(annotation, ast.Subscript) and _qualified_tail(annotation.value) in _TRANSPARENT_WRAPPERS:
207
+ return _is_nominal_id(annotation.slice)
208
+ if (
209
+ isinstance(annotation, ast.Subscript)
210
+ and _qualified_tail(annotation.value) == "Annotated"
211
+ and isinstance(annotation.slice, ast.Tuple)
212
+ ):
213
+ return bool(annotation.slice.elts) and _is_nominal_id(annotation.slice.elts[0])
214
+ if isinstance(annotation, ast.Subscript) and _qualified_tail(annotation.value) in _UNION_WRAPPERS:
215
+ members = list(annotation.slice.elts) if isinstance(annotation.slice, ast.Tuple) else [annotation.slice]
216
+ return any(_is_nominal_id(member) for member in members) and all(
217
+ _is_nominal_id(member) or _is_none(member) for member in members
218
+ )
219
+ return False