Skip to content

Commit 7663646

Browse files
Compare intercepted attribute names by value, not only by identity.
The C extension intercepts __module__ and __doc__ in the attribute get and set slots of the base object proxy, and __signature__ in the get slot of PartialCallableObjectProxy, by comparing the name against an interned string held in module state. That comparison was by identity only, so it relied on the incoming name having been interned. Names originating from Python source code always are, but a name constructed at runtime, for example by string concatenation or decoding, is not, and for such a name the interception was silently skipped. Getting __module__ or __doc__ then returned the copy captured in the instance dict when the proxy was created rather than the current value on the wrapped object, setting them stored the value on the proxy instead of the wrapped object, and __signature__ was forwarded to the wrapped callable. Add a wrapt_name_equals() helper which keeps the identity comparison as the fast path and falls back to a value comparison, guarded by a length check so that the cost for non matching names is a single integer comparison, following the pattern CPython uses in super_getattro(). Use it at all three sites and add tests exercising each attribute with a non interned name.
1 parent 5fc1e3f commit 7663646

4 files changed

Lines changed: 104 additions & 5 deletions

File tree

docs/changes.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ Version 2.4.1
3838
bound method is still reported incorrectly, for reasons outside of the
3939
control of ``wrapt``. See the "Known Issues" documentation for details.
4040

41+
* The C extension intercepts the ``__module__`` and ``__doc__`` attributes
42+
by name in its attribute get and set slots so that they are forwarded to
43+
the wrapped object. The name was compared by identity against an interned
44+
string, which relied on the attribute name having been interned. Names
45+
originating from Python source code always are, but a name constructed at
46+
runtime, for example by string concatenation or by decoding, is not, and
47+
for such a name the interception was skipped. Getting the attribute then
48+
returned the value captured when the proxy was created rather than the
49+
current value on the wrapped object, and setting it stored the value on
50+
the proxy rather than the wrapped object. The comparison now falls back to
51+
comparing by value when the identity check fails, guarded by a length
52+
check so that the cost for non matching names is unchanged.
53+
4154
Version 2.4.0
4255
-------------
4356

src/wrapt/_wrappers.c

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,35 @@ static inline PyObject *wrapt_acquire_field(PyObject *owner, PyObject **field)
205205
return value;
206206
}
207207

208+
/* Test whether an attribute name matches one of the interned strings held
209+
* in module state. Attribute names arriving from Python code are interned
210+
* by the compiler, so a pointer comparison against the interned string is
211+
* nearly always sufficient and is the fast path. A name constructed at
212+
* runtime, for example by string concatenation or decoding, is not
213+
* interned and would never match by pointer, so fall back to a value
214+
* comparison. That comparison is guarded by a length check so that the
215+
* cost for the overwhelming majority of non matching names is a single
216+
* integer comparison. This mirrors the pattern used by CPython itself in
217+
* super_getattro() for __class__.
218+
*
219+
* The name must be a str. This holds for the attribute get and set slots
220+
* where this is used, since PyObject_GetAttr() and PyObject_SetAttr()
221+
* reject any other type before the slot is reached. */
222+
223+
static inline int wrapt_name_equals(PyObject *name, PyObject *interned)
224+
{
225+
if (name == interned)
226+
return 1;
227+
228+
if (!PyUnicode_Check(name))
229+
return 0;
230+
231+
if (PyUnicode_GET_LENGTH(name) != PyUnicode_GET_LENGTH(interned))
232+
return 0;
233+
234+
return PyUnicode_Compare(name, interned) == 0;
235+
}
236+
208237
/* Convenience form for the common case of the wrapped object field. */
209238

210239
static inline PyObject *wrapt_acquire_wrapped(WraptObjectProxyObject *self)
@@ -3336,9 +3365,9 @@ static PyObject *WraptObjectProxy_getattro(WraptObjectProxyObject *self,
33363365
* __module__/__doc__ strings that type.__module__ reads via raw dict
33373366
* lookup. */
33383367

3339-
if (name == state->str_module)
3368+
if (wrapt_name_equals(name, state->str_module))
33403369
return WraptObjectProxy_get_module(self);
3341-
if (name == state->str_doc)
3370+
if (wrapt_name_equals(name, state->str_doc))
33423371
return WraptObjectProxy_get_doc(self);
33433372

33443373
object = PyObject_GenericGetAttr((PyObject *)self, name);
@@ -3418,9 +3447,9 @@ static int WraptObjectProxy_setattro(WraptObjectProxyObject *self,
34183447
* values in the type dict (from PyType_FromModuleAndSpec) would cause
34193448
* wrapt_type_has_attr to match and GenericSetAttr to store locally. */
34203449

3421-
if (name == state->str_module)
3450+
if (wrapt_name_equals(name, state->str_module))
34223451
return WraptObjectProxy_set_module(self, value);
3423-
if (name == state->str_doc)
3452+
if (wrapt_name_equals(name, state->str_doc))
34243453
return WraptObjectProxy_set_doc(self, value);
34253454

34263455
if (wrapt_type_has_attr(Py_TYPE(self), name))
@@ -4071,7 +4100,7 @@ static PyObject *WraptPartialCallableObjectProxy_getattro(
40714100
if (!state)
40724101
return NULL;
40734102

4074-
if (name == state->str_signature)
4103+
if (wrapt_name_equals(name, state->str_signature))
40754104
return WraptPartialCallableObjectProxy_get_signature(self);
40764105

40774106
return WraptObjectProxy_getattro((WraptObjectProxyObject *)self, name);

tests/core/test_callable_object_proxy.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,22 @@ def func0(a, b, c=1):
205205

206206
self.assertFalse(hasattr(func0, "__signature__"))
207207

208+
def test_attribute_via_non_interned_name(self):
209+
# The C extension intercepts __signature__ by name in the attribute
210+
# get slot. A name constructed at runtime is not interned and must
211+
# still be matched, rather than being forwarded to the wrapped
212+
# callable which has no such attribute.
213+
214+
def func0(a, b, c=1):
215+
pass
216+
217+
name = "".join(list("__signature__"))
218+
219+
self.assertEqual(
220+
getattr(wrapt.partial(func0, 1), name),
221+
inspect.signature(functools.partial(func0, 1)),
222+
)
223+
208224
def test_bound_method_limitation(self):
209225
# When the wrapped callable is an already bound method, inspect
210226
# decides based on the class of the object, which the proxy reports

tests/core/test_type_module_attribute.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,5 +325,46 @@ def func2():
325325
self.assertEqual(wrapper.__module__, "module2")
326326

327327

328+
class TestNonInternedAttributeNames(unittest.TestCase):
329+
"""The C extension intercepts __module__ and __doc__ by name in the
330+
attribute get and set slots. Names arriving from Python code are
331+
interned, but names constructed at runtime are not, and the interception
332+
must work for those too rather than falling through to a stale copy.
333+
"""
334+
335+
@staticmethod
336+
def dynamic(name):
337+
# Build an equal but distinct, non interned string object.
338+
result = "".join(list(name))
339+
assert result == name and result is not name
340+
return result
341+
342+
def test_get_module_and_doc_via_non_interned_name(self):
343+
def function():
344+
"""original doc"""
345+
346+
proxy = BaseObjectProxy(function)
347+
348+
function.__module__ = "changed.module"
349+
function.__doc__ = "changed doc"
350+
351+
self.assertEqual(getattr(proxy, self.dynamic("__module__")), "changed.module")
352+
self.assertEqual(getattr(proxy, self.dynamic("__doc__")), "changed doc")
353+
354+
def test_set_module_and_doc_via_non_interned_name(self):
355+
def function():
356+
"""original doc"""
357+
358+
proxy = BaseObjectProxy(function)
359+
360+
setattr(proxy, self.dynamic("__module__"), "set.module")
361+
setattr(proxy, self.dynamic("__doc__"), "set doc")
362+
363+
self.assertEqual(function.__module__, "set.module")
364+
self.assertEqual(function.__doc__, "set doc")
365+
self.assertEqual(proxy.__module__, "set.module")
366+
self.assertEqual(proxy.__doc__, "set doc")
367+
368+
328369
if __name__ == "__main__":
329370
unittest.main()

0 commit comments

Comments
 (0)