Skip to content

Commit 3967c67

Browse files
Refuse the pre-0.15 scoping hook, and support Python 3.14 (v0.19.5).
A class still carrying get_queryset, the name row scoping used before 0.15, was neither read nor reported. It looks scoped to its author and is not, so every row it was written to hide comes back. Nothing in the schema changes shape on upgrade, which is what made it quiet. It is now refused when the type is defined, whatever warn_missing_scope is set to: that flag is about an absent hook, not a misnamed one. Python 3.14 made annotations lazy (PEP 649), which broke two things. Reading a class's own annotations through __dict__ reported none mid-creation, and the empty dict written back dropped every annotation the class declared. And functools.wraps now carries the lazy annotation function rather than the computed annotations, so wrapping a resolver whose annotations had been set explicitly left the wrapper with none at all. Both are fixed, along with a third case the reporter had not reached: a resolver's return type has to be set along the whole __wrapped__ chain, because inspect.signature follows it. The suite passes on 3.12, 3.13 and 3.14. Reported by @jayashankarvr in #2, with the diagnosis for both clusters. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 111027d commit 3967c67

10 files changed

Lines changed: 313 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "strawberry-orm"
3-
version = "0.19.4"
3+
version = "0.19.5"
44
description = "Unified, backend-agnostic ORM abstraction for Strawberry GraphQL"
55
readme = "README.md"
66
license = "MIT"

src/strawberry_orm/_async.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@
1414
AwaitableOrValue = T | Awaitable[T]
1515

1616

17+
def keep_annotations(wrapper: Any, wrapped: Any) -> Any:
18+
"""Give *wrapper* the annotations *wrapped* declares.
19+
20+
From 3.14 ``functools.wraps`` carries over the lazy annotation function
21+
rather than the computed annotations, and assigning ``__annotations__``
22+
clears that function - so wrapping a resolver whose annotations were set
23+
explicitly leaves the wrapper with none at all. Strawberry reads a field's
24+
arguments and return type from there.
25+
"""
26+
wrapper.__annotations__ = dict(getattr(wrapped, "__annotations__", {}))
27+
return wrapper
28+
29+
1730
def in_async_context() -> bool:
1831
"""Return ``True`` when called from a running event loop."""
1932
try:

src/strawberry_orm/backends/_base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ def _annotate_filter_relation_presence(FilterCls: type) -> None:
5555

5656

5757
_ROW_SCOPE_HOOK = "scope_rows"
58+
#: What the hook was called before 0.15. Left in place it is simply not read,
59+
#: so the rows it was written to hide come back instead.
60+
_LEGACY_ROW_SCOPE_HOOK = "get_queryset"
5861

5962

6063
_KNOWN_FILTER_KEYS = frozenset({"field", "object", "all", "any", "not_", "one_of"})
@@ -1883,6 +1886,18 @@ def _process_type_annotations(
18831886

18841887
if isinstance(vars(cls).get(_ROW_SCOPE_HOOK), classmethod):
18851888
self._register_type_scope(cls, model, type_name)
1889+
elif isinstance(vars(cls).get(_LEGACY_ROW_SCOPE_HOOK), classmethod):
1890+
# Refused rather than warned about: the class reads as scoped and
1891+
# is not, so every row it meant to hide is being returned. That is
1892+
# a widening of what a caller can read, and it happens on upgrade
1893+
# without anything in the schema changing shape.
1894+
raise ValueError(
1895+
f"{type_name} defines {_LEGACY_ROW_SCOPE_HOOK}, which was "
1896+
f"renamed to {_ROW_SCOPE_HOOK} in 0.15 and is no longer read. "
1897+
f"Left as it is, {model.__name__} rows load unscoped and the "
1898+
f"rows it was written to hide are returned. Rename it to "
1899+
f"{_ROW_SCOPE_HOOK}."
1900+
)
18861901

18871902
self._check_missing_scope(cls, model, type_name)
18881903

src/strawberry_orm/core.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
await_maybe,
2828
await_maybe_blocking,
2929
in_async_context,
30+
keep_annotations,
3031
materialize_result,
3132
run_orm_work,
3233
run_orm_work_blocking,
@@ -956,8 +957,21 @@ def resolver(*args: Any, **kw: Any) -> Any: # noqa: F811
956957
)
957958
return call_scope(scope, generated(*args, **kw), info)
958959

960+
keep_annotations(resolver, generated)
961+
959962
if node_type is not None:
960-
resolver.__annotations__["return"] = list[node_type]
963+
# inspect.signature follows __wrapped__, so every function in
964+
# the chain has to carry the return type, not just the outermost.
965+
# Assign rather than mutate: from 3.14 a wrapper and the
966+
# function it wraps no longer share one annotations dict.
967+
return_ann = list[node_type]
968+
target: Any = resolver
969+
while target is not None:
970+
target.__annotations__ = {
971+
**target.__annotations__,
972+
"return": return_ann,
973+
}
974+
target = getattr(target, "__wrapped__", None)
961975

962976
# On a backend that materializes asynchronously the field has to be
963977
# async, or the connection machinery takes its sync path and ends up
@@ -972,6 +986,8 @@ def resolver(*args: Any, **kw: Any) -> Any: # noqa: F811
972986
async def resolver(*args: Any, **kwargs: Any) -> Any: # noqa: F811
973987
return sync_resolver(*args, **kwargs)
974988

989+
keep_annotations(resolver, sync_resolver)
990+
975991
extensions = list(self._kwargs.get("extensions") or [])
976992
extensions.append(
977993
_AutoFilterOrderExtension(

src/strawberry_orm/payload.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
import strawberry
2828

29-
from strawberry_orm._async import in_async_context, run_sync
29+
from strawberry_orm._async import in_async_context, keep_annotations, run_sync
3030

3131

3232
@dataclass(frozen=True)
@@ -196,7 +196,7 @@ async def _offloaded() -> Any:
196196

197197
return _offloaded()
198198

199-
return resolver
199+
return keep_annotations(resolver, fn)
200200

201201

202202
class PayloadFactory:

src/strawberry_orm/types.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,34 @@ class OperationInfo:
4747
auto = strawberry.auto
4848

4949

50+
def _own_annotations(owner: type) -> dict[str, Any] | None:
51+
"""The annotations declared on *owner* itself, or ``None`` if it has none.
52+
53+
From 3.14 annotations are lazy (PEP 649), so a class that has them may
54+
carry ``__annotate__`` and no ``__annotations__`` entry yet. Reading
55+
``__dict__`` alone reports nothing in that case, and writing a fresh dict
56+
back would drop every annotation the class declared.
57+
"""
58+
existing = owner.__dict__.get("__annotations__")
59+
if existing is not None:
60+
return existing
61+
try:
62+
import annotationlib
63+
except ImportError: # pragma: no cover - Python < 3.14
64+
return None
65+
66+
# Mid-class-creation the annotations are reachable through neither
67+
# ``__annotations__`` nor ``__annotate__``, so ask for them directly.
68+
# FORWARDREF so a reference to a type defined later resolves to a
69+
# ForwardRef rather than raising, which is normal in these schemas.
70+
# Exercised on 3.14 runs; the coverage gate runs on 3.12, where the import
71+
# above has already returned.
72+
return ( # pragma: no cover
73+
annotationlib.get_annotations(owner, format=annotationlib.Format.FORWARDREF)
74+
or None
75+
)
76+
77+
5078
@dataclass
5179
class FieldDefinition:
5280
"""Metadata attached to fields created via orm.field()."""
@@ -68,12 +96,15 @@ def __set_name__(self, owner: type, name: str) -> None:
6896
a class annotation. This runs while the class is being created, which
6997
is before ``@orm.type`` reads annotations.
7098
"""
71-
annotations = owner.__dict__.get("__annotations__")
99+
annotations = _own_annotations(owner)
72100
if self.declared_type is not None:
73101
if annotations is None:
74102
annotations = {}
75-
owner.__annotations__ = annotations
76103
annotations.setdefault(name, self.declared_type)
104+
# Assign rather than mutate: the dict may have been materialised
105+
# from a lazy annotation function, in which case nothing is
106+
# watching it.
107+
owner.__annotations__ = annotations
77108
elif self.scope is not None and name not in (annotations or {}):
78109
raise TypeError(
79110
f"{owner.__name__}.{name} has no type. Annotate the attribute "
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""The 0.14 name for the row-scoping hook must not be ignored in silence.
2+
3+
``get_queryset`` became ``scope_rows`` in 0.15. A class still carrying the old
4+
name looks scoped to its author and is not, so every row it was meant to hide
5+
is returned. The upgrade path has to fail loudly rather than quietly widen
6+
what a caller can read.
7+
"""
8+
9+
import pytest
10+
import strawberry
11+
12+
from strawberry_orm import StrawberryORM
13+
from strawberry_orm.types import auto
14+
from tests.backends.django.models import Post as SAPost
15+
16+
17+
def _orm(**kwargs):
18+
return StrawberryORM.for_django(lazy_resolution="off", **kwargs)
19+
20+
21+
@pytest.mark.django_db
22+
class TestRenamedScopeHook:
23+
@pytest.fixture(autouse=True)
24+
def _seed(self, seed):
25+
pass
26+
27+
def _build(self, orm):
28+
@orm.type(SAPost)
29+
class PostType:
30+
id: auto
31+
title: auto
32+
33+
@classmethod
34+
def get_queryset(cls, query, info):
35+
return query.filter(is_published=True)
36+
37+
@strawberry.type
38+
class Query:
39+
posts: list[PostType] = orm.field.eager()
40+
41+
return orm.schema(query=Query)
42+
43+
def test_the_old_name_is_refused(self):
44+
with pytest.raises(ValueError, match="get_queryset"):
45+
self._build(_orm(warn_missing_scope=False))
46+
47+
def test_the_message_names_the_replacement(self):
48+
with pytest.raises(ValueError, match="scope_rows"):
49+
self._build(_orm(warn_missing_scope=False))
50+
51+
def test_it_is_refused_even_with_the_warning_turned_off(self):
52+
"""warn_missing_scope covers an absent hook, not a misnamed one."""
53+
with pytest.raises(ValueError):
54+
self._build(_orm(warn_missing_scope=False))
55+
56+
def test_the_new_name_still_works(self):
57+
orm = _orm(warn_missing_scope=False)
58+
59+
@orm.type(SAPost)
60+
class PostType:
61+
id: auto
62+
title: auto
63+
64+
@classmethod
65+
def scope_rows(cls, query, info):
66+
return query.filter(is_published=True)
67+
68+
@strawberry.type
69+
class Query:
70+
posts: list[PostType] = orm.field.eager()
71+
72+
result = orm.schema(query=Query).execute_sync(
73+
"{ posts { title } }", context_value={}
74+
)
75+
assert result.errors is None, result.errors
76+
titles = [p["title"] for p in result.data["posts"]]
77+
assert "Draft Post" not in titles, titles
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""The 0.14 name for the row-scoping hook must not be ignored in silence.
2+
3+
``get_queryset`` became ``scope_rows`` in 0.15. A class still carrying the old
4+
name looks scoped to its author and is not, so every row it was meant to hide
5+
is returned. The upgrade path has to fail loudly rather than quietly widen
6+
what a caller can read.
7+
"""
8+
9+
import pytest
10+
import strawberry
11+
12+
from strawberry_orm import StrawberryORM
13+
from strawberry_orm.types import auto
14+
from tests.backends.sqlalchemy.models import Post as SAPost
15+
16+
17+
def _orm(**kwargs):
18+
return StrawberryORM.for_sqlalchemy(
19+
dialect="sqlite", lazy_resolution="off", **kwargs
20+
)
21+
22+
23+
class TestRenamedScopeHook:
24+
@pytest.fixture(autouse=True)
25+
def _session(self, sa_session, seed):
26+
self._s = sa_session
27+
28+
def _build(self, orm):
29+
@orm.type(SAPost)
30+
class PostType:
31+
id: auto
32+
title: auto
33+
34+
@classmethod
35+
def get_queryset(cls, query, info):
36+
return query.where(SAPost.is_published.is_(True))
37+
38+
@strawberry.type
39+
class Query:
40+
posts: list[PostType] = orm.field.eager()
41+
42+
return orm.schema(query=Query)
43+
44+
def test_the_old_name_is_refused(self):
45+
with pytest.raises(ValueError, match="get_queryset"):
46+
self._build(_orm(warn_missing_scope=False))
47+
48+
def test_the_message_names_the_replacement(self):
49+
with pytest.raises(ValueError, match="scope_rows"):
50+
self._build(_orm(warn_missing_scope=False))
51+
52+
def test_it_is_refused_even_with_the_warning_turned_off(self):
53+
"""warn_missing_scope covers an absent hook, not a misnamed one."""
54+
with pytest.raises(ValueError):
55+
self._build(_orm(warn_missing_scope=False))
56+
57+
def test_the_new_name_still_works(self):
58+
orm = _orm(warn_missing_scope=False)
59+
60+
@orm.type(SAPost)
61+
class PostType:
62+
id: auto
63+
title: auto
64+
65+
@classmethod
66+
def scope_rows(cls, query, info):
67+
return query.where(SAPost.is_published.is_(True))
68+
69+
@strawberry.type
70+
class Query:
71+
posts: list[PostType] = orm.field.eager()
72+
73+
result = orm.schema(query=Query).execute_sync(
74+
"{ posts { title } }", context_value={"session": self._s}
75+
)
76+
assert result.errors is None, result.errors
77+
titles = [p["title"] for p in result.data["posts"]]
78+
assert "Draft Post" not in titles, titles

0 commit comments

Comments
 (0)