sqlmodel-ext 0.4.2__py3-none-any.whl → 0.5.1__py3-none-any.whl

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 (46) hide show
  1. sqlmodel_ext/__init__.py +199 -118
  2. sqlmodel_ext/_compat.py +4 -2
  3. sqlmodel_ext/_exceptions.py +1 -1
  4. sqlmodel_ext/_sa_type.py +20 -8
  5. sqlmodel_ext/_type_unwrap.py +117 -0
  6. sqlmodel_ext/base.py +963 -302
  7. sqlmodel_ext/check_derived.py +565 -0
  8. sqlmodel_ext/constants.py +76 -0
  9. sqlmodel_ext/dependencies.py +178 -0
  10. sqlmodel_ext/derived_decls.py +818 -0
  11. sqlmodel_ext/field_types/__init__.py +504 -67
  12. sqlmodel_ext/field_types/_internal/path.py +24 -12
  13. sqlmodel_ext/field_types/dialects/postgresql/__init__.py +16 -7
  14. sqlmodel_ext/field_types/dialects/postgresql/array.py +232 -218
  15. sqlmodel_ext/field_types/dialects/postgresql/jsonb_types.py +140 -52
  16. sqlmodel_ext/field_types/dialects/postgresql/numpy_vector.py +43 -27
  17. sqlmodel_ext/field_types/ip_address.py +41 -5
  18. sqlmodel_ext/field_types/mixins/__init__.py +1 -1
  19. sqlmodel_ext/field_types/mixins/module_name_mixin.py +2 -2
  20. sqlmodel_ext/field_types/url.py +6 -6
  21. sqlmodel_ext/mixins/__init__.py +63 -23
  22. sqlmodel_ext/mixins/_uuid.py +69 -0
  23. sqlmodel_ext/mixins/cached_table.py +969 -391
  24. sqlmodel_ext/mixins/constants.py +17 -0
  25. sqlmodel_ext/mixins/exceptions.py +84 -0
  26. sqlmodel_ext/mixins/migration_cache_invalidation.py +243 -0
  27. sqlmodel_ext/mixins/mixin_table_scan.py +96 -0
  28. sqlmodel_ext/mixins/optimistic_lock.py +92 -16
  29. sqlmodel_ext/mixins/polymorphic.py +84 -38
  30. sqlmodel_ext/mixins/relation_preload.py +507 -27
  31. sqlmodel_ext/mixins/resource_quota.py +318 -0
  32. sqlmodel_ext/mixins/table.py +866 -162
  33. sqlmodel_ext/mixins/trgm_searchable.py +230 -0
  34. sqlmodel_ext/pagination.py +213 -49
  35. sqlmodel_ext/py.typed +0 -0
  36. sqlmodel_ext/relation_load_checker.py +3043 -501
  37. sqlmodel_ext/select.py +466 -0
  38. sqlmodel_ext/session.py +633 -105
  39. sqlmodel_ext/unset.py +189 -0
  40. sqlmodel_ext-0.5.1.dist-info/METADATA +1934 -0
  41. sqlmodel_ext-0.5.1.dist-info/RECORD +50 -0
  42. {sqlmodel_ext-0.4.2.dist-info → sqlmodel_ext-0.5.1.dist-info}/WHEEL +1 -1
  43. sqlmodel_ext-0.5.1.dist-info/entry_points.txt +2 -0
  44. sqlmodel_ext-0.4.2.dist-info/METADATA +0 -1518
  45. sqlmodel_ext-0.4.2.dist-info/RECORD +0 -34
  46. {sqlmodel_ext-0.4.2.dist-info → sqlmodel_ext-0.5.1.dist-info}/licenses/LICENSE +0 -0
sqlmodel_ext/__init__.py CHANGED
@@ -20,154 +20,235 @@ Quick start::
20
20
  user = await user.save(session)
21
21
  users = await User.get(session, fetch_mode="all")
22
22
  """
23
- __version__ = "0.4.2"
23
+ __version__ = "0.5.1"
24
+
25
+ # Every re-export below uses the redundant ``X as X`` form: the package ships
26
+ # ``py.typed``, and type checkers treat a plain ``from m import X`` in a typed
27
+ # package as private (consumers would get ``reportPrivateImportUsage``).
24
28
 
25
29
  # Base
26
- from sqlmodel_ext.base import SQLModelBase, ExtraIgnoreModelBase, CustomTableArg
30
+ from sqlmodel_ext.base import (
31
+ SQLModelBase as SQLModelBase,
32
+ ExtraIgnoreModelBase as ExtraIgnoreModelBase,
33
+ CustomTableArg as CustomTableArg,
34
+ SQLModelExtConfig as SQLModelExtConfig,
35
+ )
36
+
37
+ # Tri-state semantics ("not provided" vs null) and the optional wire value
38
+ from sqlmodel_ext.unset import Unset as Unset, OMITTED_SENTINEL as OMITTED_SENTINEL
39
+
40
+ # Field metadata markers
41
+ from sqlmodel_ext.constants import EXCLUDE_IF_NONE as EXCLUDE_IF_NONE
27
42
 
28
- # Enhanced session (cache-aware commit/reset/refresh/execute)
29
- from sqlmodel_ext.session import AsyncSession
43
+ # ``sqlmodel.select`` with overloads for 5-9 column projections (same object at runtime)
44
+ from sqlmodel_ext.select import select as select
45
+
46
+ # Enhanced session (cache-aware commit/reset/refresh/execute, REPEATABLE READ retries)
47
+ from sqlmodel_ext.session import (
48
+ AsyncSession as AsyncSession,
49
+ SessionFactory as SessionFactory,
50
+ RepeatableReadSnapshotConflictError as RepeatableReadSnapshotConflictError,
51
+ SerializationRetryExhaustedError as SerializationRetryExhaustedError,
52
+ )
30
53
 
31
54
  # Exceptions
32
- from sqlmodel_ext._exceptions import RecordNotFoundError
55
+ from sqlmodel_ext._exceptions import RecordNotFoundError as RecordNotFoundError
33
56
 
34
57
  # Pagination
35
58
  from sqlmodel_ext.pagination import (
36
- ListResponse,
37
- TimeFilterRequest,
38
- PaginationRequest,
39
- TableViewRequest,
59
+ DEFAULT_PAGE_SIZE as DEFAULT_PAGE_SIZE,
60
+ MAX_PAGE_SIZE as MAX_PAGE_SIZE,
61
+ MAX_TABLE_VIEW_OFFSET as MAX_TABLE_VIEW_OFFSET,
62
+ ListResponse as ListResponse,
63
+ TimeFilterRequest as TimeFilterRequest,
64
+ PageWindowRequest as PageWindowRequest,
65
+ PaginationRequest as PaginationRequest,
66
+ TableViewRequest as TableViewRequest,
40
67
  )
41
68
 
69
+ # FastAPI dependency for query-parameter DTOs (cross-field errors -> 422; requires the `fastapi` extra when called)
70
+ from sqlmodel_ext.dependencies import query_dependency as query_dependency
71
+
42
72
  # Mixins
43
73
  from sqlmodel_ext.mixins import (
44
74
  # Table
45
- SESSION_FOR_UPDATE_KEY,
46
- TableBaseMixin,
47
- UUIDTableBaseMixin,
48
- rel,
49
- cond,
75
+ SESSION_FOR_UPDATE_KEY as SESSION_FOR_UPDATE_KEY,
76
+ SESSION_REPEATABLE_READ_KEY as SESSION_REPEATABLE_READ_KEY,
77
+ GroupSumRow as GroupSumRow,
78
+ TableBaseMixin as TableBaseMixin,
79
+ UUIDTableBaseMixin as UUIDTableBaseMixin,
80
+ rel as rel,
81
+ cond as cond,
82
+ uuid7 as uuid7,
83
+ # Delete / keyset exceptions
84
+ FK_DELETE_RESTRICT_FALLBACK_MESSAGE as FK_DELETE_RESTRICT_FALLBACK_MESSAGE,
85
+ ResourceReferencedError as ResourceReferencedError,
86
+ KeysetCursorError as KeysetCursorError,
87
+ KeysetCursorInvalidError as KeysetCursorInvalidError,
88
+ KeysetCursorUnsupportedError as KeysetCursorUnsupportedError,
50
89
  # Polymorphic
51
- PolymorphicBaseMixin,
52
- AutoPolymorphicIdentityMixin,
53
- DeferredIndex,
54
- create_subclass_id_mixin,
55
- register_sti_columns_for_all_subclasses,
56
- register_sti_column_properties_for_all_subclasses,
90
+ PolymorphicBaseMixin as PolymorphicBaseMixin,
91
+ AutoPolymorphicIdentityMixin as AutoPolymorphicIdentityMixin,
92
+ DeferredIndex as DeferredIndex,
93
+ create_subclass_id_mixin as create_subclass_id_mixin,
94
+ register_sti_columns_for_all_subclasses as register_sti_columns_for_all_subclasses,
95
+ register_sti_column_properties_for_all_subclasses as register_sti_column_properties_for_all_subclasses,
57
96
  # Optimistic Lock
58
- OptimisticLockMixin,
59
- OptimisticLockError,
97
+ OptimisticLockMixin as OptimisticLockMixin,
98
+ OptimisticLockError as OptimisticLockError,
60
99
  # Relation Preload
61
- RelationPreloadMixin,
62
- requires_relations,
63
- requires_for_update,
100
+ RelationPreloadMixin as RelationPreloadMixin,
101
+ requires_relations as requires_relations,
102
+ requires_for_update as requires_for_update,
103
+ requires_locked_param as requires_locked_param,
104
+ requires_read_committed as requires_read_committed,
105
+ requires_repeatable_read as requires_repeatable_read,
106
+ validate_locked_instances as validate_locked_instances,
64
107
  # Cached Table
65
- CachedTableBaseMixin,
108
+ CachedTableBaseMixin as CachedTableBaseMixin,
109
+ CACHE_TTL_HOT as CACHE_TTL_HOT,
110
+ CACHE_TTL_WARM as CACHE_TTL_WARM,
111
+ CACHE_TTL_COLD as CACHE_TTL_COLD,
112
+ collect_migration_invalidation_tasks as collect_migration_invalidation_tasks,
113
+ run_pending_migration_cache_invalidations as run_pending_migration_cache_invalidations,
66
114
  # Info Response DTOs
67
- IntIdInfoMixin,
68
- UUIDIdInfoMixin,
69
- DatetimeInfoMixin,
70
- IntIdDatetimeInfoMixin,
71
- UUIDIdDatetimeInfoMixin,
115
+ IntIdInfoMixin as IntIdInfoMixin,
116
+ UUIDIdInfoMixin as UUIDIdInfoMixin,
117
+ DatetimeInfoMixin as DatetimeInfoMixin,
118
+ IntIdDatetimeInfoMixin as IntIdDatetimeInfoMixin,
119
+ UUIDIdDatetimeInfoMixin as UUIDIdDatetimeInfoMixin,
120
+ # Resource quota
121
+ ResourceQuotaMixin as ResourceQuotaMixin,
122
+ QuotaExceededError as QuotaExceededError,
123
+ QuotaOwnerNotFoundError as QuotaOwnerNotFoundError,
124
+ CallerDidNotCommitError as CallerDidNotCommitError,
125
+ # Mixin table scan
126
+ MixinTableScanMixin as MixinTableScanMixin,
127
+ # Trigram search (PostgreSQL pg_trgm)
128
+ BIGRAM_FUNCTION_SQL as BIGRAM_FUNCTION_SQL,
129
+ TrgmSearchableMixin as TrgmSearchableMixin,
130
+ TrgmSearchRequest as TrgmSearchRequest,
72
131
  )
73
132
 
74
133
  # Field Types
75
134
  from sqlmodel_ext.field_types import (
76
135
  # Path types
77
- DirectoryPathType,
78
- FilePathType,
136
+ DirectoryPathType as DirectoryPathType,
137
+ FilePathType as FilePathType,
79
138
  # String constraints
80
- Str16,
81
- Str24,
82
- Str32,
83
- Str36,
84
- Str48,
85
- Str64,
86
- Str100,
87
- Str128,
88
- Str255,
89
- Str256,
90
- Str500,
91
- Str512,
92
- Str2048,
93
- Text1K,
94
- Text1024,
95
- Text2K,
96
- Text2500,
97
- Text3K,
98
- Text5K,
99
- Text8K,
100
- Text10K,
101
- Text16K,
102
- Text32K,
103
- Text48K,
104
- Text60K,
105
- Text64K,
106
- Text100K,
107
- Text128K,
108
- Text1M,
109
- NonEmptyStr64,
110
- NonEmptyStr128,
111
- NonEmptyStr256,
112
- NonEmptyStrippedStr64,
113
- NonEmptyStrippedStr128,
114
- NonEmptyStrippedStr256,
115
- Sha256Hex,
116
- BCP47LanguageCode,
139
+ max_length_of as max_length_of,
140
+ Str1 as Str1,
141
+ Str16 as Str16,
142
+ Str24 as Str24,
143
+ Str32 as Str32,
144
+ Str36 as Str36,
145
+ Str48 as Str48,
146
+ Str64 as Str64,
147
+ Str100 as Str100,
148
+ Str128 as Str128,
149
+ Str255 as Str255,
150
+ Str256 as Str256,
151
+ Str500 as Str500,
152
+ Str512 as Str512,
153
+ Str2048 as Str2048,
154
+ Text1K as Text1K,
155
+ Text1024 as Text1024,
156
+ Text2K as Text2K,
157
+ Text2500 as Text2500,
158
+ Text3K as Text3K,
159
+ Text3072 as Text3072,
160
+ Text4K as Text4K,
161
+ Text5K as Text5K,
162
+ Text8K as Text8K,
163
+ Text10K as Text10K,
164
+ Text16K as Text16K,
165
+ Text32K as Text32K,
166
+ Text48K as Text48K,
167
+ Text60K as Text60K,
168
+ Text64K as Text64K,
169
+ Text100K as Text100K,
170
+ Text128K as Text128K,
171
+ Text1M as Text1M,
172
+ NonEmptyStr64 as NonEmptyStr64,
173
+ NonEmptyStr128 as NonEmptyStr128,
174
+ NonEmptyStr256 as NonEmptyStr256,
175
+ NonEmptyStrippedStr32 as NonEmptyStrippedStr32,
176
+ NonEmptyStrippedStr64 as NonEmptyStrippedStr64,
177
+ NonEmptyStrippedStr128 as NonEmptyStrippedStr128,
178
+ NonEmptyStrippedStr256 as NonEmptyStrippedStr256,
179
+ Sha256Hex as Sha256Hex,
180
+ BCP47LanguageCode as BCP47LanguageCode,
181
+ SingleLineStr64 as SingleLineStr64,
182
+ SearchQueryStr64 as SearchQueryStr64,
183
+ HttpHeaderName as HttpHeaderName,
117
184
  # Numeric constraints
118
- INT32_MAX,
119
- INT64_MAX,
120
- JS_MAX_SAFE_INTEGER,
121
- Port,
122
- Percentage,
123
- PositiveInt,
124
- NonNegativeInt,
125
- PositiveBigInt,
126
- NonNegativeBigInt,
127
- PositiveFloat,
128
- NonNegativeFloat,
185
+ INT32_MIN as INT32_MIN,
186
+ INT32_MAX as INT32_MAX,
187
+ INT64_MAX as INT64_MAX,
188
+ JS_MAX_SAFE_INTEGER as JS_MAX_SAFE_INTEGER,
189
+ Port as Port,
190
+ Percentage as Percentage,
191
+ PositiveInt as PositiveInt,
192
+ NonNegativeInt as NonNegativeInt,
193
+ PositiveBigInt as PositiveBigInt,
194
+ NonNegativeBigInt as NonNegativeBigInt,
195
+ SignedBigInt as SignedBigInt,
196
+ PositiveFloat as PositiveFloat,
197
+ NonNegativeFloat as NonNegativeFloat,
129
198
  # Decimal constraints (NUMERIC(p, s) + sign + JSON-string serialization)
130
- SignedDecimal38_18,
131
- NonNegativeDecimal38_18,
132
- PositiveDecimal38_18,
133
- OptionalNonNegativeDecimal38_18,
134
- SignedDecimal20_10,
135
- NonNegativeDecimal20_10,
136
- OptionalNonNegativeDecimal20_10,
199
+ SignedDecimal38_18 as SignedDecimal38_18,
200
+ NonNegativeDecimal38_18 as NonNegativeDecimal38_18,
201
+ PositiveDecimal38_18 as PositiveDecimal38_18,
202
+ OptionalNonNegativeDecimal38_18 as OptionalNonNegativeDecimal38_18,
203
+ OptionalSignedDecimal38_18 as OptionalSignedDecimal38_18,
204
+ SignedWriteDecimal38_18 as SignedWriteDecimal38_18,
205
+ NonNegativeWriteDecimal38_18 as NonNegativeWriteDecimal38_18,
206
+ PositiveWriteDecimal38_18 as PositiveWriteDecimal38_18,
207
+ OptionalNonNegativeWriteDecimal38_18 as OptionalNonNegativeWriteDecimal38_18,
208
+ OptionalSignedWriteDecimal38_18 as OptionalSignedWriteDecimal38_18,
209
+ SignedSumDecimal38_18 as SignedSumDecimal38_18,
210
+ DECIMAL_38_18_COLUMN_DIGITS as DECIMAL_38_18_COLUMN_DIGITS,
211
+ DECIMAL_38_18_WRITE_DIGITS as DECIMAL_38_18_WRITE_DIGITS,
212
+ DECIMAL_38_18_PLACES as DECIMAL_38_18_PLACES,
213
+ SignedDecimal20_10 as SignedDecimal20_10,
214
+ NonNegativeDecimal20_10 as NonNegativeDecimal20_10,
215
+ OptionalNonNegativeDecimal20_10 as OptionalNonNegativeDecimal20_10,
216
+ NullableNonNegativeDecimal20_10 as NullableNonNegativeDecimal20_10,
137
217
  # Bounded-length list aliases
138
- List,
139
- List1,
140
- List2,
141
- List3,
142
- List7,
143
- List10,
144
- List16,
145
- List20,
146
- List32,
147
- List40,
148
- List50,
149
- List64,
150
- List100,
151
- List128,
152
- List200,
153
- List256,
154
- List1024,
218
+ List as List,
219
+ List1 as List1,
220
+ List2 as List2,
221
+ List3 as List3,
222
+ List7 as List7,
223
+ List10 as List10,
224
+ List16 as List16,
225
+ List20 as List20,
226
+ List32 as List32,
227
+ List40 as List40,
228
+ List50 as List50,
229
+ List64 as List64,
230
+ List100 as List100,
231
+ List128 as List128,
232
+ List200 as List200,
233
+ List256 as List256,
234
+ List1024 as List1024,
155
235
  # Custom types
156
- IPAddress,
157
- Url,
158
- HttpUrl,
159
- WebSocketUrl,
160
- SafeHttpUrl,
161
- UnsafeURLError,
162
- validate_not_private_host,
163
- ModuleNameMixin,
236
+ IPAddress as IPAddress,
237
+ ClientIPAddress as ClientIPAddress,
238
+ Url as Url,
239
+ HttpUrl as HttpUrl,
240
+ WebSocketUrl as WebSocketUrl,
241
+ SafeHttpUrl as SafeHttpUrl,
242
+ UnsafeURLError as UnsafeURLError,
243
+ validate_not_private_host as validate_not_private_host,
244
+ ModuleNameMixin as ModuleNameMixin,
164
245
  )
165
246
 
166
247
  # Relation Load Checker (static analysis)
167
248
  from sqlmodel_ext.relation_load_checker import (
168
- RelationLoadChecker,
169
- RelationLoadWarning,
170
- RelationLoadCheckMiddleware,
171
- run_model_checks,
172
- mark_app_check_completed,
249
+ RelationLoadChecker as RelationLoadChecker,
250
+ RelationLoadWarning as RelationLoadWarning,
251
+ RelationLoadCheckMiddleware as RelationLoadCheckMiddleware,
252
+ run_model_checks as run_model_checks,
253
+ mark_app_check_completed as mark_app_check_completed,
173
254
  )
sqlmodel_ext/_compat.py CHANGED
@@ -9,7 +9,9 @@ Import this module for side effects before using SQLModelBase.
9
9
  import sys
10
10
 
11
11
  if sys.version_info >= (3, 14):
12
- import annotationlib # noqa: F401
12
+ # The checker analyzes at pythonVersion 3.12 (the lowest supported version), where this
13
+ # version-gated block is statically unreachable; it does run on 3.14+.
14
+ import annotationlib # noqa: F401 # pyright: ignore[reportUnreachable]
13
15
  import types
14
16
  from enum import StrEnum
15
17
  from typing import Literal, Union, get_args, get_origin
@@ -101,7 +103,7 @@ if sys.version_info >= (3, 14):
101
103
  if non_none_args:
102
104
  first_arg = non_none_args[0]
103
105
  # Check for Annotated SA type metadata first (e.g. Array[T] | None).
104
- # Array[T] desugars to Annotated[list[T], _ArrayTypeHandler(T)];
106
+ # Array[T] desugars to Annotated[list[T], ArrayTypeHandler(T)];
105
107
  # wrapping with | None produces Union[Annotated[...], None],
106
108
  # so the SA type must be extracted from the inner Annotated.
107
109
  first_arg_type_name = type(first_arg).__name__
@@ -12,5 +12,5 @@ class RecordNotFoundError(Exception):
12
12
  status_code: int = 404
13
13
 
14
14
  def __init__(self, detail: str = "Not found"):
15
- self.detail = detail
15
+ self.detail: str = detail
16
16
  super().__init__(detail)
sqlmodel_ext/_sa_type.py CHANGED
@@ -2,15 +2,27 @@
2
2
  Internal helpers for extracting SQLAlchemy types from annotations.
3
3
 
4
4
  These are used by the metaclass to inject sa_type into Field definitions.
5
+
6
+ Internal protocol, not part of the public API: the functions carry public names
7
+ only because :mod:`sqlmodel_ext.base` (the metaclass) imports them across module
8
+ boundaries; the module itself stays private (``_sa_type``).
5
9
  """
6
10
  import sys
7
11
  import typing
8
- from typing import Any, Mapping, get_args, get_origin, get_type_hints
12
+ from collections.abc import Mapping
13
+ from typing import Any, get_args, get_origin, get_type_hints
14
+
9
15
 
10
- from sqlalchemy.orm import Mapped
16
+ def _null_schema_handler(_source_type: Any) -> None:
17
+ """
18
+ Stand-in for pydantic's ``GetCoreSchemaHandler`` when probing a type's
19
+ ``__get_pydantic_core_schema__`` for ``metadata['sa_type']``: the probe
20
+ only reads the metadata and never needs the delegated inner schema.
21
+ """
22
+ return None
11
23
 
12
24
 
13
- def _extract_sa_type_from_annotation(annotation: Any) -> Any | None:
25
+ def extract_sa_type_from_annotation(annotation: Any) -> Any | None:
14
26
  """
15
27
  Extract SQLAlchemy type from a type annotation.
16
28
 
@@ -40,7 +52,7 @@ def _extract_sa_type_from_annotation(annotation: Any) -> Any | None:
40
52
  try:
41
53
  schema = item.__get_pydantic_core_schema__(
42
54
  annotation,
43
- lambda x: None,
55
+ _null_schema_handler,
44
56
  )
45
57
  if isinstance(schema, dict) and 'metadata' in schema:
46
58
  sa_type = schema['metadata'].get('sa_type')
@@ -54,7 +66,7 @@ def _extract_sa_type_from_annotation(annotation: Any) -> Any | None:
54
66
  try:
55
67
  schema = annotation.__get_pydantic_core_schema__(
56
68
  annotation,
57
- lambda x: None,
69
+ _null_schema_handler,
58
70
  )
59
71
  if isinstance(schema, dict) and 'metadata' in schema:
60
72
  sa_type = schema['metadata'].get('sa_type')
@@ -66,7 +78,7 @@ def _extract_sa_type_from_annotation(annotation: Any) -> Any | None:
66
78
  return None
67
79
 
68
80
 
69
- def _resolve_annotations(attrs: dict[str, Any]) -> tuple[
81
+ def resolve_annotations(attrs: dict[str, Any]) -> tuple[
70
82
  dict[str, Any],
71
83
  dict[str, str],
72
84
  Mapping[str, Any],
@@ -112,7 +124,7 @@ def _resolve_annotations(attrs: dict[str, Any]) -> tuple[
112
124
  return dict(evaluated), {}, module_globals, localns
113
125
 
114
126
 
115
- def _evaluate_annotation_from_string(
127
+ def evaluate_annotation_from_string(
116
128
  field_name: str,
117
129
  annotation_strings: dict[str, str],
118
130
  current_type: Any,
@@ -129,7 +141,7 @@ def _evaluate_annotation_from_string(
129
141
  return current_type
130
142
 
131
143
  expr = annotation_strings.get(field_name)
132
- if not expr or not isinstance(expr, str):
144
+ if not expr:
133
145
  return current_type
134
146
 
135
147
  try:
@@ -0,0 +1,117 @@
1
+ """
2
+ Type-annotation unwrapping helpers (shared mechanical operations).
3
+
4
+ Extracts the inner concrete type from wrappers such as ``Annotated[X, ...]``,
5
+ ``X | None`` or ``ListResponse[X]``. Pure type-system operations with no domain
6
+ semantics and no state, hence plain module-level functions.
7
+
8
+ **Why a dedicated unwrapper is needed**: Pydantic v2 concrete generics (e.g.
9
+ ``ListResponse[FooResponse]``) are fully concrete ``ModelMetaclass`` instances;
10
+ ``typing.get_origin()`` / ``typing.get_args()`` **both return empty values** for
11
+ them. The real generic arguments live in
12
+ ``__pydantic_generic_metadata__['args']``. Using only the standard ``typing``
13
+ API would make every generic response model opaque.
14
+
15
+ Consumer: :mod:`sqlmodel_ext.relation_load_checker` (RLC005 / RLC012 drill down
16
+ from a ``response_model`` to its DTO / ORM classes).
17
+ """
18
+ import types
19
+ import typing
20
+ from typing import Annotated, Any, Final
21
+
22
+ # ``typing.Union`` as a runtime value: the ``get_origin()`` of ``Optional[X]`` /
23
+ # ``Union[X, Y]`` on Python < 3.14 (``X | Y`` has origin ``types.UnionType``).
24
+ # reportDeprecated targets spelling *annotations* with ``Union`` (use ``X | Y``);
25
+ # an identity-comparison target has no non-deprecated spelling, so the single
26
+ # reference lives here and every origin check in the package compares against
27
+ # this name (internal protocol, not part of the public API).
28
+ TYPING_UNION: Final = typing.Union # pyright: ignore[reportDeprecated]
29
+
30
+
31
+ def unwrap_to_class(hint: Any) -> type | None:
32
+ """
33
+ Extract the actual class from a type annotation.
34
+
35
+ Handles ``Annotated[X, ...]``, ``X | None``, ``Optional[X]`` and returns the
36
+ innermost concrete type. Generic containers are **not** drilled into
37
+ (``ListResponse[X]`` returns the container class itself); use
38
+ :func:`unwrap_generic_to_dto_class` for that.
39
+
40
+ :param hint: any type annotation object
41
+ :returns: the actual type, or ``None`` if it cannot be extracted
42
+ """
43
+ origin = typing.get_origin(hint)
44
+
45
+ # Annotated[X, ...] -> unwrap X
46
+ if origin is Annotated:
47
+ args = typing.get_args(hint)
48
+ if args:
49
+ return unwrap_to_class(args[0])
50
+
51
+ # X | None (UnionType) or Optional[X] (Union[X, None])
52
+ if origin is types.UnionType or origin is TYPING_UNION:
53
+ args = typing.get_args(hint)
54
+ non_none = [a for a in args if a is not type(None)]
55
+ if len(non_none) == 1:
56
+ return unwrap_to_class(non_none[0])
57
+
58
+ # Plain class
59
+ if isinstance(hint, type):
60
+ return hint
61
+
62
+ return None
63
+
64
+
65
+ def get_pydantic_generic_args(hint: Any) -> tuple[Any, ...]:
66
+ """
67
+ Extract the type arguments of a generic (compatible with Pydantic v2 concrete generics).
68
+
69
+ Pydantic v2's ``ListResponse[T]`` creates a fully concrete ``ModelMetaclass``
70
+ instance for which ``typing.get_origin()`` / ``typing.get_args()`` return
71
+ empty values. The real generic arguments are in
72
+ ``__pydantic_generic_metadata__['args']``.
73
+
74
+ :param hint: any type annotation object
75
+ :returns: the generic arguments, or an empty tuple if there are none
76
+ """
77
+ # Standard typing API first
78
+ args = typing.get_args(hint)
79
+ if args:
80
+ return args
81
+ # Pydantic concrete-generic fallback
82
+ pgm = getattr(hint, '__pydantic_generic_metadata__', None)
83
+ if pgm is not None:
84
+ return pgm.get('args', ())
85
+ return ()
86
+
87
+
88
+ def unwrap_generic_to_dto_class(hint: Any) -> type | None:
89
+ """
90
+ Recursively extract a DTO class (a class with ``model_fields``) from a type annotation.
91
+
92
+ Container types (Pydantic generic models such as ``ListResponse``) are skipped
93
+ and their generic arguments are drilled into. For a union annotation the
94
+ **first** member that yields a DTO wins; callers that need every member must
95
+ iterate ``get_pydantic_generic_args`` / ``typing.get_args`` themselves.
96
+
97
+ :param hint: any type annotation object
98
+ :returns: the inner DTO class, or ``None`` if it cannot be extracted
99
+ """
100
+ # Pydantic generic container? (has __pydantic_generic_metadata__ with a non-empty origin)
101
+ pgm = getattr(hint, '__pydantic_generic_metadata__', None)
102
+ is_pydantic_generic = pgm is not None and pgm.get('origin') is not None
103
+
104
+ if not is_pydantic_generic:
105
+ cls = unwrap_to_class(hint)
106
+ if cls is not None and hasattr(cls, 'model_fields'):
107
+ return cls
108
+
109
+ # Recurse into generic arguments
110
+ for arg in get_pydantic_generic_args(hint):
111
+ if arg is type(None):
112
+ continue
113
+ result = unwrap_generic_to_dto_class(arg)
114
+ if result is not None:
115
+ return result
116
+
117
+ return None