Skip to content

Commit 5d874f1

Browse files
committed
fix(sdk): reject a factory class at registration
plugins=[MyFactory] -- the factory class rather than an instance of it -- passed the registration check, because create_plugin read off the class is a plain function and therefore callable. The per-invocation call supplies only the info, Python binds it to self, and the resulting TypeError is contained like any other factory failure: instrumentation is silently absent for the lifetime of the function. Registration now binds one positional argument to the member's signature, which no factory code runs. A bound method, a classmethod, a staticmethod and a __call__ on an instance all bind; an instance method read off the class does not. A callable whose signature cannot be read is accepted on the member alone, because a missing description of a factory is not evidence of a broken one.
1 parent 3bc7d1e commit 5d874f1

2 files changed

Lines changed: 153 additions & 16 deletions

File tree

packages/aws-durable-execution-sdk-python/src/aws_durable_execution_sdk_python/plugin_discovery.py

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import inspect
34
import logging
45
import os
56
from collections.abc import Mapping, Sequence
@@ -17,6 +18,10 @@
1718
PLUGIN_ENTRY_POINT_GROUP = "aws_durable_execution.plugins"
1819
PLUGIN_ENVIRONMENT_VARIABLE = "DURABLE_EXECUTION_PLUGINS"
1920

21+
# Stands in for the InvocationStartInfo when a factory's signature is checked at
22+
# registration. Only the bind is performed, so nothing reads it.
23+
_ARGUMENT_PROBE = object()
24+
2025

2126
def _parse_configured_plugin_names(environment: Mapping[str, str]) -> list[str]:
2227
configured_plugins = environment.get(PLUGIN_ENVIRONMENT_VARIABLE)
@@ -58,21 +63,57 @@ def _is_plugin_factory(value: object) -> bool:
5863
"""Report whether a value has the shape of a plugin factory.
5964
6065
:class:`DurableInstrumentationPluginFactory` declares one method, so the
61-
shape is one member: a callable ``create_plugin``. The attribute is fetched
62-
and tested for callability rather than merely for presence, because an
63-
object carrying a non-callable ``create_plugin`` would otherwise pass here
64-
and fail at invocation time.
66+
shape is one member: a ``create_plugin`` that can be called with one
67+
positional argument. The attribute is fetched and tested rather than merely
68+
checked for presence, because an object carrying a non-callable
69+
``create_plugin`` would otherwise pass here and fail at invocation time.
70+
71+
Callability alone is not enough. ``plugins=[MyFactory]`` -- the factory
72+
*class* rather than an instance of it -- resolves ``create_plugin`` to a
73+
plain function whose first parameter is ``self``, which is callable. The
74+
per-invocation call supplies only the info, Python binds it to ``self``, and
75+
the resulting :exc:`TypeError` is contained like any other factory failure:
76+
telemetry is silently absent for the lifetime of the function. Binding one
77+
positional argument to the signature rejects that at registration instead.
78+
The bind is a signature operation, so no factory code runs.
79+
80+
A callable with no introspectable signature -- a C-implemented callable, for
81+
example -- is accepted on the member alone. ``inspect.signature`` raises for
82+
it, and refusing a factory because its signature could not be read would
83+
reject a usable factory over a missing description of it.
6584
6685
Structural rather than nominal, so a factory need not import the SDK
6786
protocol to satisfy it. The protocol is deliberately not
6887
``@runtime_checkable``; see its docstring.
6988
70-
The check cannot go further than one member. Whether ``create_plugin``
71-
accepts the info, and whether it returns a plugin, is only knowable by
72-
calling it, and calling it at load time is what the per-invocation factory
73-
design avoids: there is no invocation yet.
89+
The check stops there. Whether ``create_plugin`` returns a plugin is only
90+
knowable by calling it, and calling it at load time is what the
91+
per-invocation factory design avoids: there is no invocation yet. That case
92+
is checked per invocation by :meth:`PluginExecutor._create_plugins`.
7493
"""
75-
return callable(getattr(value, "create_plugin", None))
94+
create_plugin = getattr(value, "create_plugin", None)
95+
if not callable(create_plugin):
96+
return False
97+
return _accepts_one_positional_argument(create_plugin)
98+
99+
100+
def _accepts_one_positional_argument(create_plugin: object) -> bool:
101+
"""Report whether one positional argument can be bound to a callable.
102+
103+
A bound method, a ``@classmethod`` or ``@staticmethod`` read off a class, and
104+
a ``__call__`` on an instance all present the signature the SDK calls, so all
105+
three bind. An instance method read off the class does not: its first
106+
parameter is ``self``, so one argument leaves the info unbound.
107+
"""
108+
try:
109+
signature = inspect.signature(create_plugin) # type: ignore[arg-type]
110+
except (TypeError, ValueError):
111+
return True
112+
try:
113+
signature.bind(_ARGUMENT_PROBE)
114+
except TypeError:
115+
return False
116+
return True
76117

77118

78119
def _load_factory(
@@ -103,7 +144,7 @@ def _load_factory(
103144
"create_plugin(info) method returning a "
104145
"DurableInstrumentationPlugin -- but resolved to "
105146
f"{_qualified_type_name(factory)}. Name the factory instance, not a "
106-
"plugin and not a plugin class."
147+
"plugin, not a plugin class, and not the factory class."
107148
)
108149

109150
return cast(DurableInstrumentationPluginFactory, factory)
@@ -126,11 +167,15 @@ def _validate_explicit_factories(
126167
A plugin *class* is rejected, and so is any bare callable. Both were
127168
accepted while the registration type was ``Callable``: a lambda satisfied it
128169
directly, and a class satisfied it because calling a class constructs an
129-
instance. Neither carries ``create_plugin``, so ``plugins=[MyPlugin]`` and
130-
``plugins=[lambda info: MyPlugin()]`` now fail here. The replacement is a
131-
small factory class, which is also where setup work that can fail belongs. A
132-
class that declares ``create_plugin`` as a ``@classmethod`` is accepted,
133-
because the requirement is the member and not the kind of object.
170+
instance. Neither carries a ``create_plugin`` the SDK can call, so
171+
``plugins=[MyPlugin]`` and ``plugins=[lambda info: MyPlugin()]`` now fail
172+
here. A *factory* class passed instead of an instance of it fails here too:
173+
``MyFactory.create_plugin`` is callable, but its first parameter is ``self``,
174+
so the per-invocation call binds the info to ``self``. The replacement is a
175+
small factory class, instantiated, which is also where setup work that can
176+
fail belongs. A class that declares ``create_plugin`` as a ``@classmethod``
177+
or a ``@staticmethod`` is accepted, because that member presents the
178+
signature the SDK calls.
134179
"""
135180
factories = list(explicit_plugins or [])
136181
for index, factory in enumerate(factories):
@@ -140,7 +185,8 @@ class that declares ``create_plugin`` as a ``@classmethod`` is accepted,
140185
"plugin factory -- an object with a create_plugin(info) method "
141186
"returning a DurableInstrumentationPlugin -- but is "
142187
f"{_qualified_type_name(factory)}. Pass a factory rather than a "
143-
"plugin, a plugin class, or a plain callable, for example "
188+
"plugin, a plugin class, or a plain callable, and pass a factory "
189+
"instance rather than the factory class, for example "
144190
"plugins=[MyPluginFactory(exporter)]."
145191
)
146192
return factories

packages/aws-durable-execution-sdk-python/tests/plugin_discovery_test.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
from __future__ import annotations
22

3+
import inspect
34
import logging
45
import os
6+
import time
57
from unittest.mock import Mock, patch
68

79
import pytest
@@ -577,6 +579,95 @@ def create_plugin(cls, info: InvocationStartInfo) -> _ClassFactoryPlugin:
577579
assert plugin.info is INVOCATION_START_INFO
578580

579581

582+
def test_explicit_factory_class_with_an_instance_method_is_rejected() -> None:
583+
"""The factory class is not the factory, and callability does not reveal it.
584+
585+
``MyFactory.create_plugin`` read off the class is a plain function whose
586+
first parameter is ``self``, so it is callable and used to pass. The
587+
per-invocation call supplies only the info, Python binds it to ``self``, and
588+
the resulting ``TypeError`` is contained like any other factory failure:
589+
instrumentation is silently absent for the lifetime of the function. The
590+
signature is bound at registration so the mistake fails here instead.
591+
"""
592+
with pytest.raises(PluginLoadError) as error:
593+
load_configured_plugins([_PluginAFactory], environment={}) # type: ignore[list-item]
594+
595+
assert "plugins[0]" in str(error.value)
596+
assert "a factory instance rather than the factory class" in str(error.value)
597+
598+
599+
def test_discovery_rejects_a_factory_class_at_the_entry_point() -> None:
600+
"""The entry-point path applies the same signature check."""
601+
entry_point = _FakeEntryPoint("a", _PluginAFactory)
602+
603+
with (
604+
patch(
605+
"aws_durable_execution_sdk_python.plugin_discovery.metadata.entry_points",
606+
return_value=[entry_point],
607+
),
608+
pytest.raises(PluginLoadError) as error,
609+
):
610+
load_configured_plugins(
611+
None,
612+
environment={PLUGIN_ENVIRONMENT_VARIABLE: "a"},
613+
)
614+
615+
assert "must resolve to a plugin factory" in str(error.value)
616+
assert "not the factory class" in str(error.value)
617+
618+
619+
def test_explicit_class_declaring_a_static_create_plugin_is_accepted() -> None:
620+
"""A ``@staticmethod`` presents the signature the SDK calls, so it binds."""
621+
622+
class _StaticFactoryPlugin(DurableInstrumentationPlugin):
623+
def __init__(self, info: InvocationStartInfo) -> None:
624+
self.info = info
625+
626+
@staticmethod
627+
def create_plugin(info: InvocationStartInfo) -> _StaticFactoryPlugin:
628+
return _StaticFactoryPlugin(info)
629+
630+
result = load_configured_plugins([_StaticFactoryPlugin], environment={})
631+
632+
assert result == [_StaticFactoryPlugin]
633+
assert isinstance(
634+
result[0].create_plugin(INVOCATION_START_INFO), _StaticFactoryPlugin
635+
)
636+
637+
638+
def test_explicit_factory_without_an_introspectable_signature_is_accepted() -> None:
639+
"""A signature that cannot be read is not evidence of a broken factory.
640+
641+
``inspect.signature`` raises ``ValueError`` for some C-implemented callables,
642+
``time.strftime`` among them. Rejecting such a factory would refuse a usable
643+
one over a missing description of it, so the member alone decides.
644+
"""
645+
646+
class _UnreadableSignatureFactory:
647+
create_plugin = staticmethod(time.strftime)
648+
649+
factory = _UnreadableSignatureFactory()
650+
651+
with pytest.raises(ValueError, match="no signature"):
652+
inspect.signature(time.strftime)
653+
654+
assert load_configured_plugins([factory], environment={}) == [factory] # type: ignore[list-item, comparison-overlap]
655+
656+
657+
def test_explicit_factory_taking_no_argument_is_rejected() -> None:
658+
"""A ``create_plugin`` that takes nothing cannot receive the info."""
659+
660+
class _NoArgumentFactory:
661+
def create_plugin(self) -> _PluginA:
662+
return _PluginA()
663+
664+
with pytest.raises(PluginLoadError) as error:
665+
load_configured_plugins([_NoArgumentFactory()], environment={}) # type: ignore[list-item]
666+
667+
assert "plugins[0]" in str(error.value)
668+
assert "create_plugin(info) method" in str(error.value)
669+
670+
580671
def test_explicit_factory_object_is_accepted() -> None:
581672
result = load_configured_plugins([_plugin_a_factory], environment={})
582673

0 commit comments

Comments
 (0)