Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Features
Bug Fixes
---------

- Avoid ``inspect.getfullargspec`` when checking view and subscriber
callables so Python 3.14's deferred annotation evaluation does not resolve
annotations that Pyramid does not use. See
https://github.com/Pylons/pyramid/issues/3812

Backward Incompatibilities
--------------------------

Expand Down
62 changes: 40 additions & 22 deletions src/pyramid/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from hmac import compare_digest
import inspect
import platform
import sys
import weakref

from pyramid.path import DottedNameResolver as _DottedNameResolver
Expand All @@ -19,6 +20,12 @@
__pypy__ = None
PYPY = False

_SIGNATURE_KWARGS = {}
if sys.version_info >= (3, 14): # pragma: no cover
from annotationlib import Format

_SIGNATURE_KWARGS['annotation_format'] = Format.FORWARDREF


class DottedNameResolver(_DottedNameResolver):
def __init__(
Expand Down Expand Up @@ -652,46 +659,57 @@ def wrapper(*a, **kw):
return wrapper


def get_signature(fn):
return inspect.signature(fn, **_SIGNATURE_KWARGS)


def _get_positional_parameters(sig):
return [
param
for param in sig.parameters.values()
if param.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
]


def takes_one_arg(callee, attr=None, argname=None, allow_varargs=True):
ismethod = False
if attr is None:
attr = '__call__'
if inspect.isroutine(callee):
if inspect.isroutine(callee) or inspect.isclass(callee):
fn = callee
elif inspect.isclass(callee):
fn = callee.__init__
ismethod = hasattr(fn, '__call__')
else:
try:
fn = getattr(callee, attr)
except AttributeError:
return False

argspec = inspect.getfullargspec(fn)
args = argspec[0]

if hasattr(fn, '__func__') or ismethod:
# it's an instance method
if not args:
return False
args = args[1:]
try:
sig = get_signature(fn)
except (TypeError, ValueError):
return False
args = _get_positional_parameters(sig)

if not args:
return False

if not allow_varargs and argspec.varargs:
if not allow_varargs and any(
param.kind == inspect.Parameter.VAR_POSITIONAL
for param in sig.parameters.values()
):
return False

if len(args) == 1:
return True

if argname:
defaults = argspec[3]
if defaults is None:
defaults = ()

if args[0] == argname:
if len(args) - len(defaults) == 1:
required_args = [
arg for arg in args if arg.default is inspect.Parameter.empty
]
if args[0].name == argname:
if len(required_args) == 1:
return True

return False
Expand All @@ -717,8 +735,8 @@ def is_unbound_method(fn):
is_bound = is_bound_method(fn)

if not is_bound and inspect.isroutine(fn):
spec = inspect.getfullargspec(fn)
has_self = len(spec.args) > 0 and spec.args[0] == 'self'
args = _get_positional_parameters(get_signature(fn))
has_self = len(args) > 0 and args[0].name == 'self'

if inspect.isfunction(fn) and has_self:
return True
Expand Down
21 changes: 21 additions & 0 deletions tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import sys
from unittest import mock
import unittest

from pyramid.util import bytes_, text_
Expand Down Expand Up @@ -1186,6 +1187,19 @@ def foo(bar):
getattr(foo, '__annotations__', {}).update({'bar': 'baz'})
self.assertTrue(self._callFUT(foo))

def test_function_annotations_without_getfullargspec(self):
def foo(request):
""" """

getattr(foo, '__annotations__', {}).update(
{'request': 'IRequest', 'return': 'IResponse'}
)
with mock.patch(
'pyramid.util.inspect.getfullargspec',
side_effect=AssertionError('should not be called'),
):
self.assertTrue(self._callFUT(foo))


class TestSimpleSerializer(unittest.TestCase):
def _makeOne(self):
Expand Down Expand Up @@ -1218,6 +1232,13 @@ def test_bound_method(self):
def test_unbound_method(self):
self.assertTrue(self._callFUT(self.Dummy.run))

def test_unbound_method_without_getfullargspec(self):
with mock.patch(
'pyramid.util.inspect.getfullargspec',
side_effect=AssertionError('should not be called'),
):
self.assertTrue(self._callFUT(self.Dummy.run))

def test_normal_func_unbound(self):
def func(): # pragma: no cover
return 'OK'
Expand Down