pluginkit 0.4.2__tar.gz → 0.4.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pluginkit
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: A strictly-typed, generics-first plugin framework for Python 3.13: hooks with derived return types
5
5
  Keywords: plugins,hooks,protocol,entry-points
6
6
  Author: Morten Hansen
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pluginkit"
3
- version = "0.4.2"
3
+ version = "0.4.4"
4
4
  description = "A strictly-typed, generics-first plugin framework for Python 3.13: hooks with derived return types"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Morten Hansen", email = "morten@winterop.com" }]
@@ -145,10 +145,15 @@ class AsyncPluginManager(PluginManager):
145
145
  """A PluginManager whose hooks are awaited; impls may be coroutine functions."""
146
146
 
147
147
  def _make_caller(
148
- self, name: str, spec: ExtensionPointOpts, params: tuple[str, ...], defaults: dict[str, Any]
148
+ self, name: str, spec: ExtensionPointOpts, params: tuple[str, ...], defaults: dict[str, Any], arity: int
149
149
  ) -> HookCaller:
150
- """Build an AsyncHookCaller instead of the synchronous one."""
151
- return AsyncHookCaller(name=name, spec=spec, params=params, defaults=defaults)
150
+ """Build an AsyncHookCaller; historic extension points are not supported async-side."""
151
+ if spec.historic:
152
+ raise ValueError(
153
+ f"historic extension point {name!r} is not supported by AsyncPluginManager "
154
+ f"(historic replay requires synchronous dispatch)"
155
+ )
156
+ return AsyncHookCaller(name=name, spec=spec, params=params, defaults=defaults, positional_arity=arity)
152
157
 
153
158
  @overload # type: ignore[override] # async manager returns awaitable callers
154
159
  def caller[**P, R](self, spec: FirstResultSpec[P, R]) -> AsyncFirstResultCaller[P, R]: ...
@@ -38,6 +38,30 @@ from pluginkit.markers import (
38
38
  # Sentinel distinguishing "no result yet" from a legitimate None result.
39
39
  _UNSET: Any = object()
40
40
 
41
+ # pluginkit dispatches by keyword (each impl receives the subset of named arguments it
42
+ # declares), so every parameter must be addressable by name.
43
+ _KEYWORD_DISPATCHABLE = frozenset({inspect.Parameter.POSITIONAL_OR_KEYWORD, inspect.Parameter.KEYWORD_ONLY})
44
+
45
+
46
+ def _positional_arity(signature: inspect.Signature, label: str, name: str) -> int:
47
+ """Validate that every parameter can be passed by keyword and return the positional arity.
48
+
49
+ Rejects positional-only, `*args`, and `**kwargs` parameters (none are addressable by a
50
+ fixed name), and returns how many leading parameters may *also* be passed positionally -
51
+ so a positional call binds exactly the arguments the ParamSpec allows positionally.
52
+ """
53
+ arity = 0
54
+ for param_name, parameter in signature.parameters.items():
55
+ if parameter.kind not in _KEYWORD_DISPATCHABLE:
56
+ raise ValueError(
57
+ f"{label} {name!r} parameter {param_name!r} is {parameter.kind.description}; "
58
+ f"pluginkit dispatches by keyword, so only positional-or-keyword and keyword-only "
59
+ f"parameters are supported"
60
+ )
61
+ if parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
62
+ arity += 1
63
+ return arity
64
+
41
65
 
42
66
  @dataclass(slots=True)
43
67
  class HookImpl:
@@ -55,7 +79,9 @@ class HookImpl:
55
79
  @classmethod
56
80
  def from_function(cls, plugin_name: str, function: Callable[..., Any], opts: ExtensionOpts) -> Self:
57
81
  """Build an impl, recording which keyword arguments the function declares."""
58
- params = tuple(inspect.signature(function).parameters)
82
+ signature = inspect.signature(function)
83
+ _positional_arity(signature, "implementation", getattr(function, "__qualname__", plugin_name))
84
+ params = tuple(signature.parameters)
59
85
  return cls(plugin_name=plugin_name, function=function, opts=opts, accepts=frozenset(params), params=params)
60
86
 
61
87
  def call(self, kwargs: dict[str, Any]) -> Any:
@@ -93,6 +119,9 @@ class HookCaller:
93
119
  # branded caller's ParamSpec makes them optional); they are filled in at call
94
120
  # time so the type checker and the runtime agree.
95
121
  defaults: dict[str, Any] = field(default_factory=dict)
122
+ # How many leading params may be passed positionally (the rest are keyword-only);
123
+ # -1 means "derive as all params" for callers built directly without kind info.
124
+ positional_arity: int = -1
96
125
  _impls: list[HookImpl] = field(default_factory=list)
97
126
  _wrappers: list[HookImpl] = field(default_factory=list)
98
127
  _nonwrappers: list[HookImpl] = field(default_factory=list)
@@ -102,6 +131,8 @@ class HookCaller:
102
131
  """Derive the argument-name set from the ordered parameters when given."""
103
132
  if self.params and not self.argnames:
104
133
  self.argnames = frozenset(self.params)
134
+ if self.positional_arity < 0:
135
+ self.positional_arity = len(self.params)
105
136
 
106
137
  def check_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]:
107
138
  """Fill any omitted defaulted args, then validate the call against the spec.
@@ -168,9 +199,9 @@ class HookCaller:
168
199
  """
169
200
  if not args:
170
201
  return kwargs
171
- if len(args) > len(self.params):
172
- raise TypeError(f"hook {self.name!r} takes at most {len(self.params)} positional argument(s)")
173
- positional = dict(zip(self.params, args, strict=False))
202
+ if len(args) > self.positional_arity:
203
+ raise TypeError(f"hook {self.name!r} takes at most {self.positional_arity} positional argument(s)")
204
+ positional = dict(zip(self.params[: self.positional_arity], args, strict=False))
174
205
  clash = positional.keys() & kwargs.keys()
175
206
  if clash:
176
207
  raise TypeError(f"hook {self.name!r} got multiple values for {sorted(clash)}")
@@ -203,7 +234,9 @@ class HookCaller:
203
234
  if not self.spec.historic:
204
235
  raise TypeError(f"hook {self.name!r} is not historic")
205
236
  kwargs = self.check_arguments(kwargs)
206
- self._history.append((kwargs, result_callback))
237
+ # Snapshot the event so a later mutation of the caller's dict can't change
238
+ # what plugins registered after this call replay.
239
+ self._history.append((dict(kwargs), result_callback))
207
240
  for outcome in self._collect(kwargs):
208
241
  if result_callback is not None:
209
242
  result_callback(outcome)
@@ -405,20 +438,26 @@ class PluginManager:
405
438
  if not isinstance(spec, ExtensionPointOpts):
406
439
  continue
407
440
  signature = inspect.signature(member)
441
+ arity = _positional_arity(signature, "extension point", member_name)
408
442
  params = tuple(signature.parameters)
409
443
  defaults = {
410
444
  name: parameter.default
411
445
  for name, parameter in signature.parameters.items()
412
446
  if parameter.default is not inspect.Parameter.empty
413
447
  }
448
+ if self.hook._get_caller(member_name) is not None:
449
+ raise ValueError(
450
+ f"extension point {member_name!r} is already registered; "
451
+ f"re-adding it would drop the implementations already wired to it"
452
+ )
414
453
  self._validate_spec(member_name, spec, params)
415
- self.hook._add_caller(self._make_caller(member_name, spec, params, defaults))
454
+ self.hook._add_caller(self._make_caller(member_name, spec, params, defaults, arity))
416
455
 
417
456
  def _make_caller(
418
- self, name: str, spec: ExtensionPointOpts, params: tuple[str, ...], defaults: dict[str, Any]
457
+ self, name: str, spec: ExtensionPointOpts, params: tuple[str, ...], defaults: dict[str, Any], arity: int
419
458
  ) -> HookCaller:
420
459
  """Build the caller for a spec; overridden by AsyncPluginManager."""
421
- return HookCaller(name=name, spec=spec, params=params, defaults=defaults)
460
+ return HookCaller(name=name, spec=spec, params=params, defaults=defaults, positional_arity=arity)
422
461
 
423
462
  @overload
424
463
  def caller[**P, R](self, spec: FirstResultSpec[P, R]) -> FirstResultCaller[P, R]: ...
File without changes
File without changes