pynect 0.0.0-stage → 0.2.0

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.
Files changed (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +478 -2
  3. package/SECURITY.md +34 -0
  4. package/binding.gyp +60 -0
  5. package/dist/cli/codegen.d.ts +3 -0
  6. package/dist/cli/codegen.d.ts.map +1 -0
  7. package/dist/cli/codegen.js +543 -0
  8. package/dist/cli/codegen.js.map +1 -0
  9. package/dist/cli/sync.d.ts +3 -0
  10. package/dist/cli/sync.d.ts.map +1 -0
  11. package/dist/cli/sync.js +48 -0
  12. package/dist/cli/sync.js.map +1 -0
  13. package/dist/config/project.d.ts +57 -0
  14. package/dist/config/project.d.ts.map +1 -0
  15. package/dist/config/project.js +176 -0
  16. package/dist/config/project.js.map +1 -0
  17. package/dist/core/IsolatedWorker.d.ts +39 -0
  18. package/dist/core/IsolatedWorker.d.ts.map +1 -0
  19. package/dist/core/IsolatedWorker.js +178 -0
  20. package/dist/core/IsolatedWorker.js.map +1 -0
  21. package/dist/core/PyCallable.d.ts +67 -0
  22. package/dist/core/PyCallable.d.ts.map +1 -0
  23. package/dist/core/PyCallable.js +104 -0
  24. package/dist/core/PyCallable.js.map +1 -0
  25. package/dist/core/PyContext.d.ts +76 -0
  26. package/dist/core/PyContext.d.ts.map +1 -0
  27. package/dist/core/PyContext.js +239 -0
  28. package/dist/core/PyContext.js.map +1 -0
  29. package/dist/core/PyIterator.d.ts +84 -0
  30. package/dist/core/PyIterator.d.ts.map +1 -0
  31. package/dist/core/PyIterator.js +243 -0
  32. package/dist/core/PyIterator.js.map +1 -0
  33. package/dist/core/PyModule.d.ts +53 -0
  34. package/dist/core/PyModule.d.ts.map +1 -0
  35. package/dist/core/PyModule.js +162 -0
  36. package/dist/core/PyModule.js.map +1 -0
  37. package/dist/core/PyProxy.d.ts +75 -0
  38. package/dist/core/PyProxy.d.ts.map +1 -0
  39. package/dist/core/PyProxy.js +563 -0
  40. package/dist/core/PyProxy.js.map +1 -0
  41. package/dist/core/PyRuntime.d.ts +112 -0
  42. package/dist/core/PyRuntime.d.ts.map +1 -0
  43. package/dist/core/PyRuntime.js +546 -0
  44. package/dist/core/PyRuntime.js.map +1 -0
  45. package/dist/env/PackageInstaller.d.ts +232 -0
  46. package/dist/env/PackageInstaller.d.ts.map +1 -0
  47. package/dist/env/PackageInstaller.js +628 -0
  48. package/dist/env/PackageInstaller.js.map +1 -0
  49. package/dist/env/ProjectEnvironment.d.ts +63 -0
  50. package/dist/env/ProjectEnvironment.d.ts.map +1 -0
  51. package/dist/env/ProjectEnvironment.js +379 -0
  52. package/dist/env/ProjectEnvironment.js.map +1 -0
  53. package/dist/env/PyfigAdapter.d.ts +32 -0
  54. package/dist/env/PyfigAdapter.d.ts.map +1 -0
  55. package/dist/env/PyfigAdapter.js +10 -0
  56. package/dist/env/PyfigAdapter.js.map +1 -0
  57. package/dist/env/PythonDetector.d.ts +48 -0
  58. package/dist/env/PythonDetector.d.ts.map +1 -0
  59. package/dist/env/PythonDetector.js +182 -0
  60. package/dist/env/PythonDetector.js.map +1 -0
  61. package/dist/env/PythonProvisioner.d.ts +16 -0
  62. package/dist/env/PythonProvisioner.d.ts.map +1 -0
  63. package/dist/env/PythonProvisioner.js +42 -0
  64. package/dist/env/PythonProvisioner.js.map +1 -0
  65. package/dist/env/VenvManager.d.ts +55 -0
  66. package/dist/env/VenvManager.d.ts.map +1 -0
  67. package/dist/env/VenvManager.js +153 -0
  68. package/dist/env/VenvManager.js.map +1 -0
  69. package/dist/env/executables.d.ts +3 -0
  70. package/dist/env/executables.d.ts.map +1 -0
  71. package/dist/env/executables.js +14 -0
  72. package/dist/env/executables.js.map +1 -0
  73. package/dist/index.d.ts +182 -0
  74. package/dist/index.d.ts.map +1 -0
  75. package/dist/index.js +393 -0
  76. package/dist/index.js.map +1 -0
  77. package/dist/native/NativeAddonLoader.d.ts +11 -0
  78. package/dist/native/NativeAddonLoader.d.ts.map +1 -0
  79. package/dist/native/NativeAddonLoader.js +75 -0
  80. package/dist/native/NativeAddonLoader.js.map +1 -0
  81. package/dist/plugins/Plugin.interface.d.ts +41 -0
  82. package/dist/plugins/Plugin.interface.d.ts.map +1 -0
  83. package/dist/plugins/Plugin.interface.js +12 -0
  84. package/dist/plugins/Plugin.interface.js.map +1 -0
  85. package/dist/plugins/PluginManager.d.ts +26 -0
  86. package/dist/plugins/PluginManager.d.ts.map +1 -0
  87. package/dist/plugins/PluginManager.js +174 -0
  88. package/dist/plugins/PluginManager.js.map +1 -0
  89. package/dist/plugins/builtin/NumpyPlugin.d.ts +17 -0
  90. package/dist/plugins/builtin/NumpyPlugin.d.ts.map +1 -0
  91. package/dist/plugins/builtin/NumpyPlugin.js +41 -0
  92. package/dist/plugins/builtin/NumpyPlugin.js.map +1 -0
  93. package/dist/plugins/builtin/PandasPlugin.d.ts +19 -0
  94. package/dist/plugins/builtin/PandasPlugin.d.ts.map +1 -0
  95. package/dist/plugins/builtin/PandasPlugin.js +57 -0
  96. package/dist/plugins/builtin/PandasPlugin.js.map +1 -0
  97. package/dist/plugins/builtin/TorchPlugin.d.ts +23 -0
  98. package/dist/plugins/builtin/TorchPlugin.d.ts.map +1 -0
  99. package/dist/plugins/builtin/TorchPlugin.js +50 -0
  100. package/dist/plugins/builtin/TorchPlugin.js.map +1 -0
  101. package/dist/plugins/index.d.ts +7 -0
  102. package/dist/plugins/index.d.ts.map +1 -0
  103. package/dist/plugins/index.js +12 -0
  104. package/dist/plugins/index.js.map +1 -0
  105. package/dist/serialization/DataFrameBridge.d.ts +141 -0
  106. package/dist/serialization/DataFrameBridge.d.ts.map +1 -0
  107. package/dist/serialization/DataFrameBridge.js +355 -0
  108. package/dist/serialization/DataFrameBridge.js.map +1 -0
  109. package/dist/serialization/MsgPackSerializer.d.ts +45 -0
  110. package/dist/serialization/MsgPackSerializer.d.ts.map +1 -0
  111. package/dist/serialization/MsgPackSerializer.js +242 -0
  112. package/dist/serialization/MsgPackSerializer.js.map +1 -0
  113. package/dist/serialization/NumpyBridge.d.ts +96 -0
  114. package/dist/serialization/NumpyBridge.d.ts.map +1 -0
  115. package/dist/serialization/NumpyBridge.js +323 -0
  116. package/dist/serialization/NumpyBridge.js.map +1 -0
  117. package/dist/serialization/Serializer.d.ts +78 -0
  118. package/dist/serialization/Serializer.d.ts.map +1 -0
  119. package/dist/serialization/Serializer.js +281 -0
  120. package/dist/serialization/Serializer.js.map +1 -0
  121. package/dist/types/addon.d.ts +131 -0
  122. package/dist/types/addon.d.ts.map +1 -0
  123. package/dist/types/addon.js +32 -0
  124. package/dist/types/addon.js.map +1 -0
  125. package/dist/types/config.d.ts +233 -0
  126. package/dist/types/config.d.ts.map +1 -0
  127. package/dist/types/config.js +60 -0
  128. package/dist/types/config.js.map +1 -0
  129. package/dist/types/index.d.ts +10 -0
  130. package/dist/types/index.d.ts.map +1 -0
  131. package/dist/types/index.js +12 -0
  132. package/dist/types/index.js.map +1 -0
  133. package/dist/types/python.d.ts +235 -0
  134. package/dist/types/python.d.ts.map +1 -0
  135. package/dist/types/python.js +7 -0
  136. package/dist/types/python.js.map +1 -0
  137. package/dist/utils/ErrorTranslator.d.ts +83 -0
  138. package/dist/utils/ErrorTranslator.d.ts.map +1 -0
  139. package/dist/utils/ErrorTranslator.js +210 -0
  140. package/dist/utils/ErrorTranslator.js.map +1 -0
  141. package/dist/utils/Locking.d.ts +3 -0
  142. package/dist/utils/Locking.d.ts.map +1 -0
  143. package/dist/utils/Locking.js +10 -0
  144. package/dist/utils/Locking.js.map +1 -0
  145. package/dist/utils/Logger.d.ts +27 -0
  146. package/dist/utils/Logger.d.ts.map +1 -0
  147. package/dist/utils/Logger.js +115 -0
  148. package/dist/utils/Logger.js.map +1 -0
  149. package/dist/utils/MemoryMonitor.d.ts +43 -0
  150. package/dist/utils/MemoryMonitor.d.ts.map +1 -0
  151. package/dist/utils/MemoryMonitor.js +143 -0
  152. package/dist/utils/MemoryMonitor.js.map +1 -0
  153. package/package.json +114 -4
  154. package/python/ast_inspector.py +248 -0
  155. package/python/error_handler.py +432 -0
  156. package/python/isolated_worker.py +94 -0
  157. package/python/pynect_dispatcher.py +382 -0
  158. package/python/pynect_runtime.py +574 -0
  159. package/python/serializer.py +373 -0
  160. package/python/type_inspector.py +315 -0
  161. package/scripts/build-native.js +50 -0
  162. package/scripts/cli.js +21 -0
  163. package/scripts/ensure-native.js +85 -0
  164. package/scripts/postinstall.js +40 -0
  165. package/scripts/pyfig.js +113 -0
  166. package/scripts/python-provisioner.js +23 -0
  167. package/scripts/smoke-python-boundary.js +103 -0
  168. package/src/native/NativeAddonLoader.ts +98 -0
  169. package/src/native/gil_guard.cpp +25 -0
  170. package/src/native/gil_guard.h +174 -0
  171. package/src/native/pynect_addon.cpp +942 -0
  172. package/src/native/python_bridge.cpp +904 -0
  173. package/src/native/python_bridge.h +263 -0
  174. package/src/native/thread_pool.cpp +375 -0
  175. package/src/native/thread_pool.h +185 -0
  176. package/src/native/type_converter.cpp +906 -0
  177. package/src/native/type_converter.h +271 -0
@@ -0,0 +1,382 @@
1
+ """Policy and optional validation for calls crossing the embedded bridge.
2
+
3
+ The module intentionally imports Pydantic lazily. Applications that do not use
4
+ Pydantic keep the normal Python call semantics and do not need it installed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import concurrent.futures
11
+ import fnmatch
12
+ import inspect
13
+ import json
14
+ import threading
15
+ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence
16
+ from typing import Any, get_type_hints
17
+
18
+ _CONFIG: dict[str, Any] = {
19
+ "include": [],
20
+ "exclude": [],
21
+ "typed_only": False,
22
+ "exported_only": False,
23
+ "validation": "auto",
24
+ }
25
+ _LOOP: asyncio.AbstractEventLoop | None = None
26
+ _LOOP_THREAD: threading.Thread | None = None
27
+ _LOOP_READY = threading.Event()
28
+ _LOOP_LOCK = threading.Lock()
29
+ _PENDING: dict[str, concurrent.futures.Future[Any]] = {}
30
+ _CANCELLED: set[str] = set()
31
+ _PENDING_LOCK = threading.Lock()
32
+
33
+
34
+ class DispatchPermissionError(PermissionError):
35
+ """Raised when the configured selection policy rejects a call."""
36
+
37
+
38
+ class PydanticUnavailableError(RuntimeError):
39
+ """Raised when validation is required but Pydantic is unavailable."""
40
+
41
+
42
+ def export(function: Callable[..., Any]) -> Callable[..., Any]:
43
+ """Mark a callable for exported-only runtime and codegen policies."""
44
+
45
+ setattr(function, "__pynect_export__", True)
46
+ return function
47
+
48
+
49
+ def exclude(function: Callable[..., Any]) -> Callable[..., Any]:
50
+ """Always exclude a callable from runtime calls and generated declarations."""
51
+
52
+ setattr(function, "__pynect_exclude__", True)
53
+ return function
54
+
55
+
56
+ def configure(options: Mapping[str, Any] | None = None) -> None:
57
+ """Replace dispatcher policy with normalized, JSON-compatible options."""
58
+
59
+ incoming = dict(options or {})
60
+ validation = incoming.get("validation", "auto")
61
+ if validation not in {"auto", "required", "off"}:
62
+ raise ValueError("dispatcher.validation must be 'auto', 'required', or 'off'")
63
+ _CONFIG.update(
64
+ include=_patterns(incoming.get("include")),
65
+ exclude=_patterns(incoming.get("exclude")),
66
+ typed_only=bool(incoming.get("typedOnly", False)),
67
+ exported_only=bool(incoming.get("exportedOnly", False)),
68
+ validation=validation,
69
+ )
70
+
71
+
72
+ def is_module_allowed(module_name: str) -> bool:
73
+ """Return whether importing a module can lead to an allowed callable."""
74
+
75
+ if not _is_public_path(module_name) or _matches_any(module_name, _CONFIG["exclude"]):
76
+ return False
77
+ include: list[str] = _CONFIG["include"]
78
+ if not include:
79
+ return True
80
+ return any(
81
+ fnmatch.fnmatchcase(module_name, pattern)
82
+ or pattern.startswith(f"{module_name}.")
83
+ or pattern.startswith(f"{module_name}:")
84
+ for pattern in include
85
+ )
86
+
87
+
88
+ def invoke(
89
+ function: Callable[..., Any],
90
+ args: Sequence[Any],
91
+ kwargs: Mapping[str, Any],
92
+ request_id: str = "",
93
+ execution_timeout_ms: int = 0,
94
+ ) -> Any:
95
+ """Authorize, optionally validate, execute, await, and validate one call."""
96
+
97
+ if request_id and _consume_cancelled(request_id):
98
+ raise asyncio.CancelledError(f"Python call was cancelled: {request_id}")
99
+ qualified_name = _qualified_name(function)
100
+ if not _callable_allowed(function, qualified_name):
101
+ raise DispatchPermissionError(f"Python callable is not exported: {qualified_name}")
102
+
103
+ signature = _safe_signature(function)
104
+ hints = _safe_hints(function)
105
+ bound = _bind(signature, args, kwargs) if signature is not None else None
106
+ if bound is not None:
107
+ bound.apply_defaults()
108
+ _validate_arguments(signature, hints, bound.arguments)
109
+ result = function(*bound.args, **bound.kwargs)
110
+ else:
111
+ result = function(*args, **kwargs)
112
+
113
+ if inspect.isawaitable(result):
114
+ result = _await(result, request_id, execution_timeout_ms)
115
+
116
+ if request_id and _consume_cancelled(request_id):
117
+ raise asyncio.CancelledError(f"Python call was cancelled: {request_id}")
118
+
119
+ return_annotation = hints.get("return", inspect.Signature.empty)
120
+ if return_annotation not in {inspect.Signature.empty, None, type(None)}:
121
+ result = _validate_value(return_annotation, result, "return value", serialize=True)
122
+ return result
123
+
124
+
125
+ def _bind(
126
+ signature: inspect.Signature,
127
+ args: Sequence[Any],
128
+ kwargs: Mapping[str, Any],
129
+ ) -> inspect.BoundArguments:
130
+ try:
131
+ return signature.bind(*args, **kwargs)
132
+ except TypeError:
133
+ positional = [
134
+ parameter
135
+ for parameter in signature.parameters.values()
136
+ if parameter.name not in {"self", "cls"}
137
+ and parameter.kind
138
+ in {
139
+ inspect.Parameter.POSITIONAL_ONLY,
140
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
141
+ }
142
+ ]
143
+ # The JavaScript proxy historically maps a trailing plain object to
144
+ # kwargs. Recover the common Pydantic-model case where the Python
145
+ # function actually declares one positional object parameter.
146
+ if not args and kwargs and len(positional) == 1 and positional[0].name not in kwargs:
147
+ return signature.bind(dict(kwargs))
148
+ raise
149
+
150
+
151
+ def shutdown() -> None:
152
+ """Stop the persistent asyncio loop when it was used."""
153
+
154
+ global _LOOP, _LOOP_THREAD
155
+ with _PENDING_LOCK:
156
+ pending = list(_PENDING.values())
157
+ _PENDING.clear()
158
+ _CANCELLED.clear()
159
+ for future in pending:
160
+ future.cancel()
161
+ with _LOOP_LOCK:
162
+ loop = _LOOP
163
+ thread = _LOOP_THREAD
164
+ _LOOP = None
165
+ _LOOP_THREAD = None
166
+ _LOOP_READY.clear()
167
+ if loop is not None:
168
+ loop.call_soon_threadsafe(loop.stop)
169
+ if thread is not None and thread is not threading.current_thread():
170
+ thread.join(timeout=5)
171
+
172
+
173
+ def _callable_allowed(function: Callable[..., Any], qualified_name: str) -> bool:
174
+ if getattr(function, "__pynect_exclude__", False):
175
+ return False
176
+ if not _is_public_path(qualified_name):
177
+ return False
178
+ if _matches_any(qualified_name, _CONFIG["exclude"]):
179
+ return False
180
+ include: list[str] = _CONFIG["include"]
181
+ if include and not _matches_any(qualified_name, include):
182
+ return False
183
+ if _CONFIG["exported_only"] and not getattr(function, "__pynect_export__", False):
184
+ return False
185
+ if _CONFIG["typed_only"] and not _fully_typed(function):
186
+ return False
187
+ return True
188
+
189
+
190
+ def _fully_typed(function: Callable[..., Any]) -> bool:
191
+ signature = _safe_signature(function)
192
+ if signature is None or signature.return_annotation is inspect.Signature.empty:
193
+ return False
194
+ return all(
195
+ parameter.annotation is not inspect.Parameter.empty
196
+ for parameter in signature.parameters.values()
197
+ if parameter.name not in {"self", "cls"}
198
+ )
199
+
200
+
201
+ def _validate_arguments(
202
+ signature: inspect.Signature,
203
+ hints: Mapping[str, Any],
204
+ arguments: MutableMapping[str, Any],
205
+ ) -> None:
206
+ for name, value in list(arguments.items()):
207
+ parameter = signature.parameters[name]
208
+ annotation = hints.get(name, parameter.annotation)
209
+ if annotation is inspect.Parameter.empty:
210
+ continue
211
+ if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
212
+ arguments[name] = tuple(
213
+ _validate_value(annotation, item, f"argument '{name}'") for item in value
214
+ )
215
+ elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
216
+ arguments[name] = {
217
+ key: _validate_value(annotation, item, f"argument '{name}.{key}'")
218
+ for key, item in value.items()
219
+ }
220
+ else:
221
+ arguments[name] = _validate_value(annotation, value, f"argument '{name}'")
222
+
223
+
224
+ def _validate_value(
225
+ annotation: Any,
226
+ value: Any,
227
+ label: str,
228
+ *,
229
+ serialize: bool = False,
230
+ ) -> Any:
231
+ mode = _CONFIG["validation"]
232
+ if mode == "off":
233
+ return value
234
+ try:
235
+ import pydantic
236
+ except ImportError:
237
+ if mode == "required":
238
+ raise PydanticUnavailableError(
239
+ f"Pydantic validation is required but unavailable for {label}"
240
+ )
241
+ return value
242
+
243
+ type_adapter = getattr(pydantic, "TypeAdapter", None)
244
+ if type_adapter is not None:
245
+ try:
246
+ adapter = type_adapter(annotation)
247
+ except Exception:
248
+ if mode == "required":
249
+ raise
250
+ return value
251
+ validated = adapter.validate_python(value)
252
+ return adapter.dump_python(validated, mode="json") if serialize else validated
253
+
254
+ parse_obj_as = getattr(pydantic, "parse_obj_as", None)
255
+ if parse_obj_as is None:
256
+ if mode == "required":
257
+ raise PydanticUnavailableError(f"Pydantic does not support {label}")
258
+ return value
259
+ validated = parse_obj_as(annotation, value)
260
+ if not serialize:
261
+ return validated
262
+ encoder = getattr(getattr(pydantic, "json", None), "pydantic_encoder", None)
263
+ return json.loads(json.dumps(validated, default=encoder)) if encoder else validated
264
+
265
+
266
+ def cancel(request_id: str) -> bool:
267
+ """Cancel a queued-to-loop coroutine or mark a not-yet-registered call."""
268
+
269
+ if not request_id:
270
+ return False
271
+ with _PENDING_LOCK:
272
+ future = _PENDING.get(request_id)
273
+ if future is None:
274
+ _CANCELLED.add(request_id)
275
+ return True
276
+ return future.cancel()
277
+
278
+
279
+ def _consume_cancelled(request_id: str) -> bool:
280
+ with _PENDING_LOCK:
281
+ if request_id not in _CANCELLED:
282
+ return False
283
+ _CANCELLED.remove(request_id)
284
+ return True
285
+
286
+
287
+ def _await(value: Awaitable[Any], request_id: str, timeout_ms: int) -> Any:
288
+ loop = _ensure_loop()
289
+
290
+ async def resolve() -> Any:
291
+ return await value
292
+
293
+ future = asyncio.run_coroutine_threadsafe(resolve(), loop)
294
+ if request_id:
295
+ with _PENDING_LOCK:
296
+ if request_id in _CANCELLED:
297
+ _CANCELLED.remove(request_id)
298
+ future.cancel()
299
+ else:
300
+ _PENDING[request_id] = future
301
+ try:
302
+ timeout = timeout_ms / 1000 if timeout_ms > 0 else None
303
+ return future.result(timeout=timeout)
304
+ except concurrent.futures.TimeoutError as error:
305
+ future.cancel()
306
+ raise TimeoutError(
307
+ f"Python async call exceeded execution timeout of {timeout_ms}ms"
308
+ ) from error
309
+ except concurrent.futures.CancelledError as error:
310
+ raise asyncio.CancelledError(
311
+ f"Python async call was cancelled: {request_id or '<unknown>'}"
312
+ ) from error
313
+ finally:
314
+ if request_id:
315
+ with _PENDING_LOCK:
316
+ _PENDING.pop(request_id, None)
317
+
318
+
319
+ def _ensure_loop() -> asyncio.AbstractEventLoop:
320
+ global _LOOP, _LOOP_THREAD
321
+ with _LOOP_LOCK:
322
+ if _LOOP is not None and _LOOP.is_running():
323
+ return _LOOP
324
+ loop = asyncio.new_event_loop()
325
+ _LOOP = loop
326
+
327
+ def run() -> None:
328
+ asyncio.set_event_loop(loop)
329
+ _LOOP_READY.set()
330
+ loop.run_forever()
331
+ loop.close()
332
+
333
+ _LOOP_THREAD = threading.Thread(
334
+ target=run,
335
+ name="pynect-asyncio",
336
+ daemon=True,
337
+ )
338
+ _LOOP_THREAD.start()
339
+ _LOOP_READY.wait(timeout=5)
340
+ if _LOOP is None or not _LOOP.is_running():
341
+ raise RuntimeError("Could not start the Pynect asyncio loop")
342
+ return _LOOP
343
+
344
+
345
+ def _qualified_name(function: Callable[..., Any]) -> str:
346
+ module = getattr(function, "__module__", "")
347
+ name = getattr(function, "__qualname__", getattr(function, "__name__", "<callable>"))
348
+ return f"{module}.{name}" if module else name
349
+
350
+
351
+ def _safe_signature(function: Callable[..., Any]) -> inspect.Signature | None:
352
+ try:
353
+ return inspect.signature(function)
354
+ except (TypeError, ValueError):
355
+ return None
356
+
357
+
358
+ def _safe_hints(function: Callable[..., Any]) -> dict[str, Any]:
359
+ try:
360
+ return get_type_hints(function, include_extras=True)
361
+ except Exception:
362
+ return dict(getattr(function, "__annotations__", {}))
363
+
364
+
365
+ def _patterns(value: Any) -> list[str]:
366
+ if value is None:
367
+ return []
368
+ if isinstance(value, str):
369
+ return [value]
370
+ return [str(item) for item in value]
371
+
372
+
373
+ def _matches_any(value: str, patterns: Sequence[str]) -> bool:
374
+ leaf = value.rsplit(".", 1)[-1]
375
+ return any(
376
+ fnmatch.fnmatchcase(value, pattern) or fnmatch.fnmatchcase(leaf, pattern)
377
+ for pattern in patterns
378
+ )
379
+
380
+
381
+ def _is_public_path(value: str) -> bool:
382
+ return all(not part.startswith("_") for part in value.replace(":", ".").split("."))