Skip to content

Commit ca47bb4

Browse files
Foxerineclaude
andcommitted
fix(rlc): resolve TYPE_CHECKING forward refs and unify caller_provided
`_resolve_param_models` now passes `self.model_classes` as `localns` to `typing.get_type_hints()` so string annotations referencing TYPE_CHECKING-only imports (e.g. `llm: 'LLM'`) resolve to actual classes instead of being silently dropped. Adds `_eval_string_annotation` fallback that evaluates string annotations against `func.__globals__` + `model_classes` when `get_type_hints` still fails on nested third-party ForwardRefs. `_check_model_method` now treats all parameters as caller_provided (consistent with `_check_coroutine`), not only `self`. Method parameters with model relations are caller's preload contract; flagging them with RLC003 was a false positive. Together these surface previously-silent RLC007/RLC013 misses for top-level functions whose post-commit forward-ref ORM access caused MissingGreenlet at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9316259 commit ca47bb4

1 file changed

Lines changed: 47 additions & 8 deletions

File tree

src/sqlmodel_ext/relation_load_checker.py

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,8 +1093,15 @@ def _check_model_method(
10931093
"""
10941094
Check a model method (with self tracking and @requires_relations parsing).
10951095
1096-
self is marked as caller_provided; pre-commit access skips RLC003.
1097-
But self.save() followed by relationship access still triggers RLC002.
1096+
All method parameters (``self`` plus other model-typed arguments) are marked
1097+
as caller_provided — these objects are passed in by the caller, preloading
1098+
is the caller's contract (typically declared via a docstring "preload before
1099+
calling: ..." note), so the method body must not be flagged with RLC003 for
1100+
"missing load=". This matches ``_check_coroutine``'s policy of marking all
1101+
params as caller_provided.
1102+
1103+
Post-commit relation access still triggers RLC002 / RLC007 / RLC008
1104+
(caller_provided does not exempt those checks).
10981105
"""
10991106
# Parse AST to extract @requires_relations
11001107
source_file, tree, line_offset = self._parse_function_source(func)
@@ -1114,17 +1121,19 @@ def _check_model_method(
11141121
# Detect instance method or classmethod
11151122
sig = python_inspect.signature(func)
11161123
first_param = next(iter(sig.parameters), None)
1117-
caller_provided_params: set[str] = set()
11181124

11191125
# cls -> class alias (classmethod's cls parameter doesn't enter tracked_vars,
11201126
# only used for resolving class-level calls)
11211127
class_aliases: dict[str, str] = {}
11221128
if first_param == 'self':
11231129
param_models['self'] = cls_name
1124-
caller_provided_params.add('self')
11251130
elif first_param == 'cls':
11261131
class_aliases['cls'] = cls_name
11271132

1133+
# All method params are caller-provided (self + other model-typed args
1134+
# are the caller's preload responsibility).
1135+
caller_provided_params: set[str] = set(param_models.keys())
1136+
11281137
# @requires_relations declared rels as self's dep_loads
11291138
dep_loads: dict[str, set[str]] = {}
11301139
if 'self' in param_models and decorator_loads:
@@ -1287,20 +1296,32 @@ def _resolve_param_models(self, func: Any) -> dict[str, str]:
12871296
(e.g. TYPE_CHECKING forward references), falls back to per-parameter
12881297
``__annotations__`` parsing to ensure resolvable params aren't missed.
12891298
1299+
TYPE_CHECKING forward reference handling: passes ``self.model_classes``
1300+
(all mapped ORM classes) as ``localns`` to ``get_type_hints()`` so that
1301+
string annotations like ``llm: 'LLM'`` (where ``LLM`` is imported only
1302+
under ``if TYPE_CHECKING:``) resolve to actual classes instead of being
1303+
silently dropped — silent drops cause RLC007/RLC013 false negatives.
1304+
12901305
:returns: param_name -> model_class_name
12911306
"""
12921307
param_models: dict[str, str] = {}
12931308

1309+
# Provide model_classes as localns so TYPE_CHECKING forward references resolve.
1310+
localns: dict[str, Any] = dict(self.model_classes)
12941311
try:
1295-
hints = typing.get_type_hints(func, include_extras=True)
1312+
hints = typing.get_type_hints(func, include_extras=True, localns=localns)
12961313
except Exception:
1297-
# get_type_hints may fail due to TYPE_CHECKING forward references.
1298-
# Fall back to per-parameter __annotations__ (skip unresolvable string annotations)
1314+
# Even with model_classes in scope, get_type_hints may still fail on
1315+
# nested third-party ForwardRefs. Fall back to per-parameter __annotations__:
1316+
# try to eval string annotations against globals + model_classes.
12991317
hints = {}
13001318
annotations: dict[str, Any] = getattr(func, '__annotations__', {})
13011319
for param_name, annotation in annotations.items():
13021320
if isinstance(annotation, str):
1303-
continue # Skip unresolvable string annotations
1321+
resolved = self._eval_string_annotation(annotation, func)
1322+
if resolved is not None:
1323+
hints[param_name] = resolved
1324+
continue
13041325
hints[param_name] = annotation
13051326

13061327
for param_name, hint in hints.items():
@@ -1333,6 +1354,24 @@ def _resolve_param_models(self, func: Any) -> dict[str, str]:
13331354

13341355
return param_models
13351356

1357+
def _eval_string_annotation(self, annotation: str, func: Any) -> Any:
1358+
"""
1359+
Evaluate a string annotation that ``typing.get_type_hints`` couldn't resolve.
1360+
1361+
Typical case: a TYPE_CHECKING-only import is referenced as a forward reference
1362+
(e.g. ``llm: 'LLM'`` where ``LLM`` is imported only under ``if TYPE_CHECKING:``).
1363+
Evaluates against ``func.__globals__`` merged with the known ORM model classes.
1364+
1365+
Supported forms: ``'LLM'``, ``'LLM | None'``, ``'list[LLM]'`` etc.
1366+
Returns ``None`` on failure (silent skip, matches the original fallback).
1367+
"""
1368+
globalns: dict[str, Any] = getattr(func, '__globals__', {}) or {}
1369+
localns: dict[str, Any] = dict(self.model_classes)
1370+
try:
1371+
return eval(annotation, globalns, localns) # noqa: S307 — source-code literal, scoped namespace
1372+
except Exception:
1373+
return None
1374+
13361375
def _extract_model_from_hint(self, hint: Any) -> str | None:
13371376
"""
13381377
Extract a model class name from a type annotation.

0 commit comments

Comments
 (0)