Skip to content

Commit 320c021

Browse files
authored
fix(wrapper): stop Wrap mutating the ingress it is given (#84)
`preserve_signature` wrote `__signature__`/`__annotations__` onto the ingress object. A decorator normally defines one ingress and reuses it for every function it wraps, so that write corrupted the caller's function and made every later Wrap built from the same ingress advertise the FIRST wrapped function's signature -- while still executing correctly, so nothing surfaced the lie: def shared_ingress(*args, **kwargs): return args, kwargs def f(a: int) -> int: ... def g(x: str, y: str) -> str: ... Wrap(f, ingress=shared_ingress) signature(shared_ingress) # (a: int) -> int <- caller's function mutated Sig(Wrap(g, ingress=shared_ingress)) # (a: int) -> str <- wrong, but g still works That is the worst shape of bug for this package: silent, and wrong in exactly the introspection i2 exists to provide (meshed builds DAGs from these signatures). Preserving means *the wrapper* presents func's interface, so read the signature from func and never write it onto the ingress. Defaults now come from the same source as the signature, so the two cannot disagree. Also, while in here: - `_get_return_annotation` had `x if x is not Parameter.empty else empty` three times. `empty IS Parameter.empty`, so each was a no-op wrapped around a duplicated fallback. Reduced to one "egress wins if annotated, else func" rule with doctests, including the case that made the dead branch look meaningful (an unannotated func gives `empty`, not `None` -- `None` is a real annotation meaning "returns None"). - Extracted `_is_generic_signature`, the actual predicate `'auto'` turns on, with doctests for the near-misses ((*a) alone, (x, *a, **kw)). - Dropped the unused `func` parameter from `_should_preserve_signature`. - Named the `'auto'` sentinel `AUTO_PRESERVE_SIGNATURE` so it has one definition. - Converted the numpydoc docstrings to the `:param:` style used everywhere else here. Public API unchanged. Regression tests fail on the old code and pass on the new. i2: 470 passed. meshed (biggest dependent): 60 passed. crude/o failures are pre-existing and byte-identical with and without this change. Claude-Session: https://claude.ai/code/session_01GsUw8ey8KikzNFQ1UaWWia
1 parent fe39f32 commit 320c021

2 files changed

Lines changed: 170 additions & 82 deletions

File tree

i2/tests/test_wrapper.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from i2.wrapper import (
9+
Wrap,
910
wrap,
1011
mk_ingress_from_name_mapper,
1112
rm_params,
@@ -534,3 +535,66 @@ def decorate(func=None, *, multiplier=2):
534535

535536
with pytest.raises(TypeError):
536537
decorate(_incr, func=_incr)
538+
539+
540+
# ---------------------------------------------------------------------------------------
541+
# Regression: Wrap must not mutate the ingress it is given.
542+
# `preserve_signature` used to stamp `__signature__`/`__annotations__` onto the ingress
543+
# object. A decorator normally defines one ingress and reuses it for every function it
544+
# wraps, so that write corrupted the caller's function AND made every later Wrap built
545+
# from the same ingress advertise the first-wrapped function's signature -- while still
546+
# executing correctly, so nothing surfaced the lie.
547+
548+
549+
def _shared_ingress(*args, **kwargs):
550+
"""A module-level ingress, of the shape `preserve_signature='auto'` acts on."""
551+
return args, kwargs
552+
553+
554+
def test_wrap_does_not_mutate_the_ingress():
555+
"""Wrapping must leave the caller's ingress object exactly as it was."""
556+
from inspect import signature
557+
558+
def func(a: int) -> int:
559+
return a
560+
561+
before = signature(_shared_ingress)
562+
Wrap(func, ingress=_shared_ingress)
563+
564+
assert signature(_shared_ingress) == before
565+
assert not hasattr(_shared_ingress, "__signature__")
566+
567+
568+
def test_reused_ingress_gives_each_wrap_its_own_signature():
569+
"""Two Wraps sharing one ingress must each report their own func's signature."""
570+
571+
def f(a: int) -> int:
572+
return a
573+
574+
def g(x: str, y: str) -> str:
575+
return x + y
576+
577+
wrapped_f = Wrap(f, ingress=_shared_ingress)
578+
wrapped_g = Wrap(g, ingress=_shared_ingress)
579+
580+
assert str(Sig(wrapped_f)) == "(a: int) -> int"
581+
assert str(Sig(wrapped_g)) == "(x: str, y: str) -> str"
582+
# ... and both still actually work
583+
assert wrapped_f(3) == 3
584+
assert wrapped_g("a", "b") == "ab"
585+
586+
587+
def test_wrap_signature_is_order_independent():
588+
"""Building the two Wraps in the other order must give the same signatures."""
589+
590+
def f(a: int) -> int:
591+
return a
592+
593+
def g(x: str, y: str) -> str:
594+
return x + y
595+
596+
wrapped_g = Wrap(g, ingress=_shared_ingress)
597+
wrapped_f = Wrap(f, ingress=_shared_ingress)
598+
599+
assert str(Sig(wrapped_g)) == "(x: str, y: str) -> str"
600+
assert str(Sig(wrapped_f)) == "(a: int) -> int"

i2/wrapper.py

Lines changed: 106 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@
103103
InnerKwargs = dict
104104
KwargsTrans = Callable[[OuterKwargs], InnerKwargs]
105105

106+
#: ``preserve_signature`` value meaning "decide per ingress" (see
107+
#: :func:`_should_preserve_signature`). Named rather than spelled ``'auto'`` at each
108+
#: use so the sentinel has exactly one definition.
109+
AUTO_PRESERVE_SIGNATURE = "auto"
110+
106111

107112
def identity(x):
108113
"""Transparent function, returning what's been input"""
@@ -205,92 +210,102 @@ def _defaults_and_kwdefaults_of_func(func: Callable):
205210
return sig._defaults_, sig._kwdefaults_
206211

207212

208-
def _should_preserve_signature(ingress, func, preserve_mode):
209-
"""Determine if signature should be auto-preserved from func to ingress.
213+
def _is_generic_signature(func: Callable) -> bool:
214+
"""Whether ``func``'s signature is exactly ``(*args, **kwargs)``.
210215
211-
Parameters
212-
----------
213-
ingress : callable or None
214-
The ingress function
215-
func : callable
216-
The wrapped function
217-
preserve_mode : 'auto' | True | False
218-
The preservation mode
216+
Such a signature carries no interface information, which is what makes it safe
217+
to show the wrapped function's signature in its place.
219218
220-
Returns
221-
-------
222-
bool
223-
True if signature should be preserved
219+
>>> _is_generic_signature(lambda *a, **kw: None)
220+
True
221+
>>> _is_generic_signature(lambda x, *a, **kw: None)
222+
False
223+
>>> _is_generic_signature(lambda *a: None)
224+
False
225+
>>> _is_generic_signature(lambda x: None)
226+
False
224227
"""
225-
if preserve_mode is False:
228+
try:
229+
params = list(signature(func).parameters.values())
230+
except (ValueError, TypeError): # no inspectable signature (some builtins)
226231
return False
227-
if preserve_mode is True:
228-
return True
232+
return [p.kind for p in params] == [Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD]
229233

230-
# 'auto' mode: preserve if ingress has generic (*args, **kwargs)
231-
if ingress is None:
232-
return False
233234

234-
try:
235-
ingress_sig = signature(ingress)
236-
except (ValueError, TypeError):
237-
# Can't get signature, don't preserve
238-
return False
235+
def _should_preserve_signature(ingress, preserve_mode):
236+
"""Whether the wrapper should advertise the wrapped function's signature instead
237+
of ``ingress``'s.
238+
239+
:param ingress: The incoming data transformer, or ``None``
240+
:param preserve_mode: ``True`` (always), ``False`` (never), or ``'auto'``
241+
:return: ``True`` if the wrapped function's signature should be used
239242
240-
params = list(ingress_sig.parameters.values())
243+
``'auto'`` preserves only when the ingress signature is ``(*args, **kwargs)`` and
244+
the ingress does not declare a ``__signature__`` of its own -- i.e. only when the
245+
ingress says nothing about its interface, so there is nothing to overwrite:
241246
242-
# Check if ingress has exactly (*args, **kwargs) signature
243-
if len(params) != 2:
247+
>>> _should_preserve_signature(lambda *a, **kw: None, 'auto')
248+
True
249+
>>> _should_preserve_signature(lambda a: None, 'auto')
250+
False
251+
252+
``True``/``False`` are unconditional, and are the way to override ``'auto'``'s guess:
253+
254+
>>> _should_preserve_signature(lambda a: None, True)
255+
True
256+
>>> _should_preserve_signature(lambda *a, **kw: None, False)
257+
False
258+
"""
259+
if preserve_mode != AUTO_PRESERVE_SIGNATURE:
260+
return bool(preserve_mode)
261+
if ingress is None:
244262
return False
263+
return _is_generic_signature(ingress) and not hasattr(ingress, "__signature__")
245264

246-
is_generic = (
247-
params[0].kind == Parameter.VAR_POSITIONAL
248-
and params[1].kind == Parameter.VAR_KEYWORD
249-
)
250265

251-
# Only preserve if generic and doesn't already have __signature__
252-
return is_generic and not hasattr(ingress, "__signature__")
266+
def _return_annotation_of(func: Callable) -> Any:
267+
"""The return annotation of ``func``, or ``empty`` if it has none or has no
268+
inspectable signature at all."""
269+
try:
270+
return Sig(func).return_annotation
271+
except (ValueError, TypeError):
272+
return empty
253273

254274

255275
def _get_return_annotation(func, egress):
256-
"""Get return annotation with smart fallback logic.
276+
"""Resolve the return annotation a :class:`Wrap` should advertise.
257277
258-
Fallback chain: egress annotation → func annotation → empty
278+
:param func: The wrapped function
279+
:param egress: The outgoing data transformer, or ``None``
280+
:return: The return annotation to use, or ``empty``
259281
260-
Parameters
261-
----------
262-
func : callable
263-
The wrapped function
264-
egress : callable or None
265-
The egress function
282+
The egress wins when it declares a return annotation, since it is what actually
283+
produces the wrapper's output. Otherwise the annotation falls back to ``func``'s,
284+
on the assumption that an unannotated egress does not change the type:
266285
267-
Returns
268-
-------
269-
annotation
270-
The return annotation to use, or Parameter.empty
271-
"""
272-
func_sig = Sig(func)
273-
func_return = func_sig.return_annotation
286+
>>> def f(x: int) -> str: return str(x)
287+
>>> def annotated_egress(out) -> bytes: return out.encode()
288+
>>> def bare_egress(out): return out
274289
275-
if egress is None:
276-
# No egress: use func's return annotation
277-
return func_return if func_return is not Parameter.empty else empty
290+
>>> _get_return_annotation(f, None)
291+
<class 'str'>
292+
>>> _get_return_annotation(f, annotated_egress)
293+
<class 'bytes'>
294+
>>> _get_return_annotation(f, bare_egress)
295+
<class 'str'>
278296
279-
# Egress provided: check its annotation first
280-
try:
281-
egress_sig = Sig(egress)
282-
egress_return = egress_sig.return_annotation
283-
except (ValueError, TypeError):
284-
# Can't get egress signature, fall back to func
285-
return func_return if func_return is not Parameter.empty else empty
286-
287-
if egress_return is not Parameter.empty:
288-
# Egress has annotation, use it
289-
return egress_return
297+
An unannotated ``func`` yields ``empty``, not ``None`` -- ``None`` is a legitimate
298+
annotation meaning "returns None", so the two must stay distinguishable:
290299
291-
# Egress has no annotation: fall back to func's annotation
292-
# Assumption: egress doesn't transform the type
293-
return func_return if func_return is not Parameter.empty else empty
300+
>>> def g(x): pass
301+
>>> _get_return_annotation(g, None) is empty
302+
True
303+
"""
304+
if egress is not None:
305+
egress_return = _return_annotation_of(egress)
306+
if egress_return is not empty:
307+
return egress_return
308+
return _return_annotation_of(func)
294309

295310

296311
class Wrap(_Wrap):
@@ -565,7 +580,13 @@ class Wrap(_Wrap):
565580
"""
566581

567582
def __init__(
568-
self, func, ingress=None, egress=None, *, name=None, preserve_signature="auto"
583+
self,
584+
func,
585+
ingress=None,
586+
egress=None,
587+
*,
588+
name=None,
589+
preserve_signature=AUTO_PRESERVE_SIGNATURE,
569590
):
570591
super().__init__(func, ingress, egress, name=name)
571592
ingress_sig = Sig(func)
@@ -576,7 +597,6 @@ def __init__(
576597
func
577598
)
578599
else:
579-
580600
if isinstance(ingress, MakeFromFunc):
581601
func_to_ingress = ingress # it's not the ingress function itself
582602
# ... but an ingress factory: Should make the ingress in function of func
@@ -585,15 +605,19 @@ def __init__(
585605
assert callable(ingress), f"Should be callable: {ingress}"
586606
self.ingress = ingress
587607

588-
# Apply signature preservation if needed
589-
if _should_preserve_signature(self.ingress, func, preserve_signature):
590-
# Preserve signature and annotations from func to ingress
591-
self.ingress.__signature__ = signature(func)
592-
self.ingress.__annotations__ = getattr(func, "__annotations__", {})
593-
594-
ingress_sig = Sig(self.ingress)
608+
# Preserving means *this wrapper* presents func's interface. Read it from
609+
# func directly; never write it onto self.ingress. The ingress belongs to
610+
# the caller -- a decorator typically defines one and reuses it for every
611+
# function it wraps -- so stamping __signature__ on it corrupted both the
612+
# caller's object and every later Wrap built from the same ingress.
613+
signature_source = (
614+
func
615+
if _should_preserve_signature(self.ingress, preserve_signature)
616+
else self.ingress
617+
)
618+
ingress_sig = Sig(signature_source)
595619
self.__defaults__, self.__kwdefaults__ = _defaults_and_kwdefaults_of_func(
596-
self.ingress
620+
signature_source
597621
)
598622

599623
# Set egress
@@ -974,9 +998,9 @@ def name_map(cls, wrapped, **old_to_new_name):
974998
975999
"""
9761000
new_to_old_name = {v: k for k, v in old_to_new_name.items()}
977-
assert len(new_to_old_name) == len(
978-
old_to_new_name
979-
), f"Inversion is not possible since {old_to_new_name=} has duplicate values."
1001+
assert len(new_to_old_name) == len(old_to_new_name), (
1002+
f"Inversion is not possible since {old_to_new_name=} has duplicate values."
1003+
)
9801004
return cls(
9811005
wrapped,
9821006
partial(Pipe(items_with_mapped_keys, dict), key_mapper=new_to_old_name),
@@ -2541,9 +2565,9 @@ def add_smart_defaults(
25412565
25422566
"""
25432567
names_not_in_func_arguments = smart_defaults.keys() - Sig(func).names
2544-
assert (
2545-
not names_not_in_func_arguments
2546-
), f"These weren't argument names of the {func} function: {names_not_in_func_arguments}"
2568+
assert not names_not_in_func_arguments, (
2569+
f"These weren't argument names of the {func} function: {names_not_in_func_arguments}"
2570+
)
25472571
kwargs_trans = partial(
25482572
complete_dict_applying_functions,
25492573
_only_if_name_missing=_only_if_name_missing,

0 commit comments

Comments
 (0)