calfkit-tools 0.1.0__py3-none-any.whl

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 (110) hide show
  1. calfkit_tools/__init__.py +20 -0
  2. calfkit_tools/hermes/__init__.py +0 -0
  3. calfkit_tools/hermes/_shims/__init__.py +0 -0
  4. calfkit_tools/hermes/_shims/agent/__init__.py +0 -0
  5. calfkit_tools/hermes/_shims/agent/auxiliary_client.py +11 -0
  6. calfkit_tools/hermes/_shims/agent/lsp/__init__.py +10 -0
  7. calfkit_tools/hermes/_shims/agent/lsp/range_shift.py +6 -0
  8. calfkit_tools/hermes/_shims/agent/lsp/reporter.py +11 -0
  9. calfkit_tools/hermes/_shims/agent/lsp/servers.py +4 -0
  10. calfkit_tools/hermes/_shims/gateway/__init__.py +0 -0
  11. calfkit_tools/hermes/_shims/gateway/session_context.py +24 -0
  12. calfkit_tools/hermes/_shims/gateway/status.py +28 -0
  13. calfkit_tools/hermes/_shims/hermes_cli/__init__.py +0 -0
  14. calfkit_tools/hermes/_shims/hermes_cli/_subprocess_compat.py +17 -0
  15. calfkit_tools/hermes/_shims/hermes_cli/auth.py +21 -0
  16. calfkit_tools/hermes/_shims/hermes_cli/config.py +53 -0
  17. calfkit_tools/hermes/_shims/hermes_cli/nous_account.py +13 -0
  18. calfkit_tools/hermes/_shims/hermes_cli/plugins.py +10 -0
  19. calfkit_tools/hermes/_shims/hermes_cli/profiles.py +8 -0
  20. calfkit_tools/hermes/_shims/model_tools.py +64 -0
  21. calfkit_tools/hermes/_vendor/__init__.py +0 -0
  22. calfkit_tools/hermes/_vendor/agent/__init__.py +0 -0
  23. calfkit_tools/hermes/_vendor/agent/async_utils.py +68 -0
  24. calfkit_tools/hermes/_vendor/agent/file_safety.py +640 -0
  25. calfkit_tools/hermes/_vendor/agent/i18n.py +302 -0
  26. calfkit_tools/hermes/_vendor/agent/redact.py +496 -0
  27. calfkit_tools/hermes/_vendor/agent/skill_utils.py +666 -0
  28. calfkit_tools/hermes/_vendor/agent/web_search_provider.py +185 -0
  29. calfkit_tools/hermes/_vendor/agent/web_search_registry.py +245 -0
  30. calfkit_tools/hermes/_vendor/hermes_constants.py +471 -0
  31. calfkit_tools/hermes/_vendor/locales/af.yaml +357 -0
  32. calfkit_tools/hermes/_vendor/locales/de.yaml +357 -0
  33. calfkit_tools/hermes/_vendor/locales/en.yaml +372 -0
  34. calfkit_tools/hermes/_vendor/locales/es.yaml +357 -0
  35. calfkit_tools/hermes/_vendor/locales/fr.yaml +357 -0
  36. calfkit_tools/hermes/_vendor/locales/ga.yaml +361 -0
  37. calfkit_tools/hermes/_vendor/locales/hu.yaml +357 -0
  38. calfkit_tools/hermes/_vendor/locales/it.yaml +357 -0
  39. calfkit_tools/hermes/_vendor/locales/ja.yaml +357 -0
  40. calfkit_tools/hermes/_vendor/locales/ko.yaml +357 -0
  41. calfkit_tools/hermes/_vendor/locales/pt.yaml +357 -0
  42. calfkit_tools/hermes/_vendor/locales/ru.yaml +357 -0
  43. calfkit_tools/hermes/_vendor/locales/tr.yaml +357 -0
  44. calfkit_tools/hermes/_vendor/locales/uk.yaml +357 -0
  45. calfkit_tools/hermes/_vendor/locales/zh-hant.yaml +357 -0
  46. calfkit_tools/hermes/_vendor/locales/zh.yaml +357 -0
  47. calfkit_tools/hermes/_vendor/tools/__init__.py +25 -0
  48. calfkit_tools/hermes/_vendor/tools/ansi_strip.py +44 -0
  49. calfkit_tools/hermes/_vendor/tools/approval.py +1697 -0
  50. calfkit_tools/hermes/_vendor/tools/binary_extensions.py +42 -0
  51. calfkit_tools/hermes/_vendor/tools/budget_config.py +51 -0
  52. calfkit_tools/hermes/_vendor/tools/code_execution_tool.py +1831 -0
  53. calfkit_tools/hermes/_vendor/tools/credential_files.py +454 -0
  54. calfkit_tools/hermes/_vendor/tools/env_passthrough.py +163 -0
  55. calfkit_tools/hermes/_vendor/tools/environments/__init__.py +14 -0
  56. calfkit_tools/hermes/_vendor/tools/environments/base.py +895 -0
  57. calfkit_tools/hermes/_vendor/tools/environments/daytona.py +270 -0
  58. calfkit_tools/hermes/_vendor/tools/environments/docker.py +1296 -0
  59. calfkit_tools/hermes/_vendor/tools/environments/file_sync.py +403 -0
  60. calfkit_tools/hermes/_vendor/tools/environments/local.py +697 -0
  61. calfkit_tools/hermes/_vendor/tools/environments/managed_modal.py +282 -0
  62. calfkit_tools/hermes/_vendor/tools/environments/modal.py +478 -0
  63. calfkit_tools/hermes/_vendor/tools/environments/modal_utils.py +204 -0
  64. calfkit_tools/hermes/_vendor/tools/environments/singularity.py +262 -0
  65. calfkit_tools/hermes/_vendor/tools/environments/ssh.py +319 -0
  66. calfkit_tools/hermes/_vendor/tools/file_operations.py +2292 -0
  67. calfkit_tools/hermes/_vendor/tools/file_state.py +332 -0
  68. calfkit_tools/hermes/_vendor/tools/file_tools.py +1533 -0
  69. calfkit_tools/hermes/_vendor/tools/fuzzy_match.py +860 -0
  70. calfkit_tools/hermes/_vendor/tools/interrupt.py +98 -0
  71. calfkit_tools/hermes/_vendor/tools/lazy_deps.py +623 -0
  72. calfkit_tools/hermes/_vendor/tools/managed_tool_gateway.py +192 -0
  73. calfkit_tools/hermes/_vendor/tools/patch_parser.py +622 -0
  74. calfkit_tools/hermes/_vendor/tools/path_security.py +43 -0
  75. calfkit_tools/hermes/_vendor/tools/process_registry.py +1616 -0
  76. calfkit_tools/hermes/_vendor/tools/registry.py +594 -0
  77. calfkit_tools/hermes/_vendor/tools/terminal_tool.py +2611 -0
  78. calfkit_tools/hermes/_vendor/tools/thread_context.py +120 -0
  79. calfkit_tools/hermes/_vendor/tools/tirith_security.py +820 -0
  80. calfkit_tools/hermes/_vendor/tools/todo_tool.py +279 -0
  81. calfkit_tools/hermes/_vendor/tools/tool_backend_helpers.py +182 -0
  82. calfkit_tools/hermes/_vendor/tools/tool_output_limits.py +110 -0
  83. calfkit_tools/hermes/_vendor/tools/url_safety.py +361 -0
  84. calfkit_tools/hermes/_vendor/tools/web_providers/__init__.py +0 -0
  85. calfkit_tools/hermes/_vendor/tools/web_providers/brave_free.py +137 -0
  86. calfkit_tools/hermes/_vendor/tools/web_providers/ddgs.py +104 -0
  87. calfkit_tools/hermes/_vendor/tools/web_providers/searxng.py +140 -0
  88. calfkit_tools/hermes/_vendor/tools/web_providers/tavily.py +220 -0
  89. calfkit_tools/hermes/_vendor/utils.py +376 -0
  90. calfkit_tools/hermes/node/__init__.py +44 -0
  91. calfkit_tools/hermes/node/_runtime.py +109 -0
  92. calfkit_tools/hermes/node/code.py +110 -0
  93. calfkit_tools/hermes/node/files.py +193 -0
  94. calfkit_tools/hermes/node/shell.py +162 -0
  95. calfkit_tools/hermes/node/todo.py +116 -0
  96. calfkit_tools/hermes/node/web.py +156 -0
  97. calfkit_tools/web_fetch/__init__.py +0 -0
  98. calfkit_tools/web_fetch/_vendor/__init__.py +0 -0
  99. calfkit_tools/web_fetch/_vendor/_ssrf.py +622 -0
  100. calfkit_tools/web_fetch/_vendor/common_tools/__init__.py +0 -0
  101. calfkit_tools/web_fetch/_vendor/common_tools/web_fetch.py +166 -0
  102. calfkit_tools/web_fetch/node/__init__.py +58 -0
  103. calfkit_tools/web_fetch/results.py +37 -0
  104. calfkit_tools-0.1.0.dist-info/METADATA +112 -0
  105. calfkit_tools-0.1.0.dist-info/RECORD +110 -0
  106. calfkit_tools-0.1.0.dist-info/WHEEL +4 -0
  107. calfkit_tools-0.1.0.dist-info/licenses/LICENSE +201 -0
  108. calfkit_tools-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +43 -0
  109. calfkit_tools-0.1.0.dist-info/licenses/vendor/hermes/LICENSE +21 -0
  110. calfkit_tools-0.1.0.dist-info/licenses/vendor/web_fetch/LICENSE +21 -0
@@ -0,0 +1,20 @@
1
+ """calfkit-tools — vendored agent tools for the calfkit SDK.
2
+
3
+ Tools are organised by the upstream source they were vendored from, each as a
4
+ subpackage of ``calfkit_tools``. Import the deployable tool nodes directly from a
5
+ source's ``node`` module::
6
+
7
+ from calfkit_tools.hermes.node import HERMES_NODES, terminal, todo, web_search
8
+ from calfkit_tools.web_fetch.node import web_fetch
9
+
10
+ Each subpackage keeps its vendored upstream code under ``_vendor`` (and, for
11
+ hermes, app-runtime stand-ins under ``_shims``); provenance for every source
12
+ lives outside the package in ``vendor/<source>/`` (upstream ``LICENSE`` +
13
+ ``METADATA.yaml``). See ``THIRD_PARTY_NOTICES.md``.
14
+
15
+ This module is intentionally side-effect free: importing ``calfkit_tools`` pulls
16
+ in no tool code or optional dependencies. Import the specific ``.node`` module
17
+ for the tools you need.
18
+ """
19
+
20
+ __all__: list[str] = []
File without changes
File without changes
File without changes
@@ -0,0 +1,11 @@
1
+ """Shim for hermes-agent ``agent.auxiliary_client``.
2
+
3
+ ``call_llm`` makes a secondary LLM call (used only by approval's "smart approve" path,
4
+ which is non-default). calfkit wires its own LLM elsewhere; this raises so the caller's
5
+ try/except falls back to escalation (the secure direction). Stage D may wire this to
6
+ calfkit's LLM if smart-approve is wanted.
7
+ """
8
+
9
+
10
+ def call_llm(*args, **kwargs):
11
+ raise RuntimeError("call_llm is not available in the calfkit-tools hermes shim")
@@ -0,0 +1,10 @@
1
+ """Shim for hermes-agent ``agent.lsp`` — LSP-diagnostics-on-edit enrichment.
2
+
3
+ LSP is a pure enrichment layer for file edits; every call site is gated and try/except'd.
4
+ The shim fails closed: ``get_service`` returns None so file edits proceed without
5
+ post-edit diagnostics. (Port the real ``agent/lsp`` later if diagnostics are wanted.)
6
+ """
7
+
8
+
9
+ def get_service():
10
+ return None
@@ -0,0 +1,6 @@
1
+ """Shim for ``agent.lsp.range_shift``. Identity line-shift (no LSP diagnostics)."""
2
+ from typing import Callable, Optional
3
+
4
+
5
+ def build_line_shift(pre_text: str, post_text: str) -> Callable[[int], Optional[int]]:
6
+ return lambda line: line
@@ -0,0 +1,11 @@
1
+ """Shim for ``agent.lsp.reporter``. No diagnostics to report."""
2
+
3
+
4
+ def report_for_file(*args, **kwargs) -> str:
5
+ return ""
6
+
7
+
8
+ def truncate(s: str, *, limit: int = 10000) -> str:
9
+ if s is None:
10
+ return s
11
+ return s if len(s) <= limit else s[:limit]
@@ -0,0 +1,4 @@
1
+ """Shim for ``agent.lsp.servers``. No LSP servers configured."""
2
+ from typing import List
3
+
4
+ SERVERS: List = []
File without changes
@@ -0,0 +1,24 @@
1
+ """Shim for hermes-agent ``gateway.session_context``.
2
+
3
+ Provides the symbols the vendored tree imports: ``_UNSET``, ``_VAR_MAP``,
4
+ ``get_session_env``. Upstream backs these with per-request ContextVars to inject
5
+ session-scoped env vars into subprocesses.
6
+
7
+ NOTE (Stage D): the real per-tenant isolation lives here. For the adapt stage this is a
8
+ minimal env-backed version so the tree imports and single-tenant behaviour is correct;
9
+ ``_VAR_MAP`` is empty so no session vars are injected. Stage D must replace this with the
10
+ real ContextVar mechanism (re-keyed on the calfkit session_key) — see design doc §6, §12.
11
+ """
12
+ import os
13
+ from typing import Any
14
+
15
+ _UNSET: Any = object()
16
+
17
+ # Maps session-var name -> ContextVar upstream; empty here (no injection until Stage D).
18
+ _VAR_MAP: dict = {}
19
+
20
+
21
+ def get_session_env(name: str, default: str = "") -> str:
22
+ """Return a session-scoped env value, falling back to the process environment."""
23
+ value = os.environ.get(name)
24
+ return value if value is not None else default
@@ -0,0 +1,28 @@
1
+ """Shim for hermes-agent ``gateway.status`` (faithful, tiny).
2
+
3
+ ``_pid_exists`` is a cross-platform "is this PID alive" check that does NOT signal the
4
+ target (critical on Windows, where ``os.kill(pid, 0)`` is not a no-op). Used by the
5
+ background-process registry's detached crash-recovery path.
6
+ """
7
+ import os
8
+ from typing import Optional
9
+
10
+
11
+ def _pid_exists(pid: Optional[int]) -> bool:
12
+ if not pid:
13
+ return False
14
+ try:
15
+ import psutil
16
+
17
+ return psutil.pid_exists(pid)
18
+ except Exception:
19
+ pass
20
+ try:
21
+ os.kill(pid, 0)
22
+ except ProcessLookupError:
23
+ return False
24
+ except PermissionError:
25
+ return True
26
+ except OSError:
27
+ return False
28
+ return True
File without changes
@@ -0,0 +1,17 @@
1
+ """Shim for hermes-agent ``hermes_cli._subprocess_compat`` (faithful, tiny).
2
+
3
+ ``windows_hide_flags`` returns Win32 creationflags that hide a child console window
4
+ (0 on non-Windows). Must return an int (it is passed unconditionally as Popen
5
+ ``creationflags=`` in process_registry.py), never None.
6
+ """
7
+ import platform
8
+
9
+ _IS_WINDOWS = platform.system() == "Windows"
10
+
11
+
12
+ def windows_hide_flags() -> int:
13
+ if not _IS_WINDOWS:
14
+ return 0
15
+ import subprocess
16
+
17
+ return getattr(subprocess, "CREATE_NO_WINDOW", 0)
@@ -0,0 +1,21 @@
1
+ """Shim for hermes-agent ``hermes_cli.auth``.
2
+
3
+ ``PROVIDER_REGISTRY`` feeds local.py's subprocess secret *blocklist*. Empty here is
4
+ deliberate: Stage D replaces the blocklist with an allowlist env model (design doc
5
+ §6.1), so the blocklist data is moot for the calfkit node.
6
+
7
+ SECURITY: do NOT read "empty" as "no secrets to block". local.py keeps a static
8
+ blocklist, but it does NOT cover calfkit's own infra secrets (Kafka SASL, etc.). The
9
+ subprocess env is only truly contained by Stage D's allowlist — until then this
10
+ component must not run untrusted commands in a multi-tenant worker (design §6.1, §12).
11
+
12
+ ``resolve_nous_access_token`` is a Nous-portal token resolver, irrelevant to calfkit —
13
+ returns None (callers wrap it in try/except and fail closed).
14
+ """
15
+
16
+ # Import-safe stub; real subprocess env hygiene = Stage-D allowlist.
17
+ PROVIDER_REGISTRY: dict = {}
18
+
19
+
20
+ def resolve_nous_access_token(*args, **kwargs):
21
+ return None
@@ -0,0 +1,53 @@
1
+ """Shim for hermes-agent ``hermes_cli.config`` — only the symbols the vendored tree uses.
2
+
3
+ ``get_hermes_home`` re-exports the vendored real implementation. ``cfg_get`` is the pure
4
+ nested-dict helper (faithful). The config-reading functions return empty defaults: the
5
+ vendored tools all consume them inside try/except with sane fallbacks, and real
6
+ subprocess env hygiene is handled by the Stage-D allowlist (not this blocklist-feeding
7
+ config). ``OPTIONAL_ENV_VARS`` is intentionally empty here for the same reason.
8
+ """
9
+ import os
10
+ from pathlib import Path
11
+
12
+ # Re-export the real implementation (lives in the vendored constants module).
13
+ from calfkit_tools.hermes._vendor.hermes_constants import get_hermes_home # noqa: F401
14
+
15
+ # Import-safe stub; real env hygiene = Stage-D allowlist (see design doc §6.1, §3.2).
16
+ OPTIONAL_ENV_VARS: dict = {}
17
+
18
+
19
+ def cfg_get(cfg, *keys, default=None):
20
+ """Traverse nested dict keys safely, returning ``default`` on any miss (faithful)."""
21
+ if not isinstance(cfg, dict):
22
+ return default
23
+ node = cfg
24
+ for key in keys:
25
+ if not isinstance(node, dict) or key not in node:
26
+ return default
27
+ node = node[key]
28
+ return node
29
+
30
+
31
+ def read_raw_config() -> dict:
32
+ return {}
33
+
34
+
35
+ def load_config() -> dict:
36
+ return {}
37
+
38
+
39
+ def save_config(config: dict) -> None:
40
+ # No-op: persisting host config is not a calfkit-node responsibility.
41
+ return None
42
+
43
+
44
+ def load_env() -> dict:
45
+ return {}
46
+
47
+
48
+ def get_env_value(key: str):
49
+ return os.environ.get(key)
50
+
51
+
52
+ def get_config_path() -> Path:
53
+ return get_hermes_home() / "config.yaml"
@@ -0,0 +1,13 @@
1
+ """Shim for hermes-agent ``hermes_cli.nous_account`` (Nous-portal account info).
2
+
3
+ Irrelevant to calfkit; only referenced by the managed-tool-gateway path inside
4
+ try/except. Safe no-ops.
5
+ """
6
+
7
+
8
+ def get_nous_portal_account_info(*args, **kwargs):
9
+ return None
10
+
11
+
12
+ def format_nous_portal_entitlement_message(*args, **kwargs) -> str:
13
+ return ""
@@ -0,0 +1,10 @@
1
+ """Shim for hermes-agent ``hermes_cli.plugins``.
2
+
3
+ ``invoke_hook`` dispatches user/project plugin hooks; calfkit has no such plugin
4
+ system here. Returns an empty list of hook results (callers iterate the result).
5
+ """
6
+ from typing import Any, List
7
+
8
+
9
+ def invoke_hook(hook_name: str, **kwargs: Any) -> List[Any]:
10
+ return []
@@ -0,0 +1,8 @@
1
+ """Shim for hermes-agent ``hermes_cli.profiles``.
2
+
3
+ Only ``get_active_profile_name`` is used (a Docker container label, inside try/except).
4
+ """
5
+
6
+
7
+ def get_active_profile_name() -> str:
8
+ return "default"
@@ -0,0 +1,64 @@
1
+ """Shim for hermes-agent ``model_tools`` — only the symbols the vendored tree uses.
2
+
3
+ - ``_run_async`` / ``_sanitize_tool_error``: faithful pure helpers used by the vendored
4
+ ``registry.dispatch`` (the shell/file tools register no async handlers, so ``_run_async``
5
+ is off the hot path here, but it stays behaviour-correct).
6
+ - ``handle_function_call``: the Programmatic-Tool-Calling (PTC) dispatcher used by
7
+ ``code_execution_tool``'s RPC bridge. The full upstream version layers a tool-search
8
+ bridge + hooks + guardrails; here it is a thin delegate to the vendored registry's own
9
+ ``dispatch`` (lookup + async-bridge + error-sanitize), scoped to this node's tools.
10
+ """
11
+ import asyncio
12
+ import concurrent.futures
13
+ import re
14
+
15
+ # --- _sanitize_tool_error (constants + body copied verbatim from upstream) ---------
16
+ _TOOL_ERROR_ROLE_TAG_RE = re.compile(
17
+ r'</?(?:tool_call|function_call|result|response|output|input|system|assistant|user)>',
18
+ re.IGNORECASE,
19
+ )
20
+ _TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*', re.MULTILINE)
21
+ _TOOL_ERROR_FENCE_CLOSE_RE = re.compile(r'\s*```\s*$', re.MULTILINE)
22
+ _TOOL_ERROR_CDATA_RE = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)
23
+ _TOOL_ERROR_MAX_LEN = 2000
24
+
25
+
26
+ def _sanitize_tool_error(error_msg: str) -> str:
27
+ """Strip structural framing tokens from a tool error before showing it to the model."""
28
+ if not error_msg:
29
+ return "[TOOL_ERROR] "
30
+ sanitized = _TOOL_ERROR_ROLE_TAG_RE.sub("", error_msg)
31
+ sanitized = _TOOL_ERROR_FENCE_OPEN_RE.sub("", sanitized)
32
+ sanitized = _TOOL_ERROR_FENCE_CLOSE_RE.sub("", sanitized)
33
+ sanitized = _TOOL_ERROR_CDATA_RE.sub("", sanitized)
34
+ if len(sanitized) > _TOOL_ERROR_MAX_LEN:
35
+ sanitized = sanitized[: _TOOL_ERROR_MAX_LEN - 3] + "..."
36
+ return f"[TOOL_ERROR] {sanitized}"
37
+
38
+
39
+ def _run_async(coro):
40
+ """Run a coroutine from sync code (sync->async bridge for async tool handlers).
41
+
42
+ Simplified vs. upstream's persistent-loop optimization (which keeps cached async
43
+ clients alive). The shell/file toolset registers no async handlers, so this is not
44
+ exercised on the hot path; it stays correct for any future async handler.
45
+ """
46
+ try:
47
+ running = asyncio.get_running_loop()
48
+ except RuntimeError:
49
+ running = None
50
+ if running and running.is_running():
51
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
52
+ return pool.submit(lambda: asyncio.run(coro)).result()
53
+ return asyncio.run(coro)
54
+
55
+
56
+ def handle_function_call(function_name, function_args, task_id=None, **kwargs):
57
+ """PTC dispatcher: route an in-sandbox tool call into the vendored registry.
58
+
59
+ Thin delegate to ``registry.dispatch`` (this node's own tools). Imported lazily to
60
+ avoid an import cycle (registry imports this module for ``_run_async``).
61
+ """
62
+ from calfkit_tools.hermes._vendor.tools.registry import registry
63
+
64
+ return registry.dispatch(function_name, function_args, task_id=task_id)
File without changes
File without changes
@@ -0,0 +1,68 @@
1
+ """Async/sync bridging helpers.
2
+
3
+ The codebase has ~30 sites that schedule a coroutine onto an event loop from a
4
+ worker thread via :func:`asyncio.run_coroutine_threadsafe`. That function can
5
+ raise :class:`RuntimeError` (e.g. the loop was closed during a shutdown race),
6
+ and when it does the coroutine object is never awaited and never closed —
7
+ which triggers a ``"coroutine '<name>' was never awaited"`` RuntimeWarning and
8
+ leaks the coroutine's frame until GC.
9
+
10
+ :func:`safe_schedule_threadsafe` wraps the call, closes the coroutine on
11
+ scheduling failure, and returns ``None`` (instead of a half-formed future) so
12
+ callers can branch cleanly:
13
+
14
+ fut = safe_schedule_threadsafe(coro, loop)
15
+ if fut is None:
16
+ return # or fallback behavior
17
+ fut.result(timeout=5)
18
+
19
+ The helper deliberately does NOT also handle ``future.result()`` failures —
20
+ that is a separate concern. Once the loop has accepted the coroutine, its
21
+ lifecycle belongs to the loop, not the scheduling thread.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import logging
27
+ from concurrent.futures import Future
28
+ from typing import Any, Coroutine, Optional
29
+
30
+
31
+ _DEFAULT_LOGGER = logging.getLogger(__name__)
32
+
33
+
34
+ def safe_schedule_threadsafe(
35
+ coro: Coroutine[Any, Any, Any],
36
+ loop: Optional[asyncio.AbstractEventLoop],
37
+ *,
38
+ logger: Optional[logging.Logger] = None,
39
+ log_message: str = "Failed to schedule coroutine on loop",
40
+ log_level: int = logging.DEBUG,
41
+ ) -> Optional[Future]:
42
+ """Schedule ``coro`` on ``loop`` from a sync context, leak-safe.
43
+
44
+ Returns the :class:`concurrent.futures.Future` on success, or ``None`` if
45
+ the loop is missing or :func:`asyncio.run_coroutine_threadsafe` raised
46
+ (e.g. the loop was closed during a shutdown race). In all failure paths
47
+ the coroutine is :meth:`close`-d so it does not trigger
48
+ ``"coroutine was never awaited"`` warnings or leak its frame.
49
+
50
+ Callers retain full control over what to do with the returned future
51
+ (call ``.result(timeout=...)``, attach ``add_done_callback``, ignore it
52
+ fire-and-forget, etc.).
53
+ """
54
+ log = logger if logger is not None else _DEFAULT_LOGGER
55
+
56
+ if loop is None:
57
+ if asyncio.iscoroutine(coro):
58
+ coro.close()
59
+ log.log(log_level, "%s: loop is None", log_message)
60
+ return None
61
+
62
+ try:
63
+ return asyncio.run_coroutine_threadsafe(coro, loop)
64
+ except Exception as exc:
65
+ if asyncio.iscoroutine(coro):
66
+ coro.close()
67
+ log.log(log_level, "%s: %s", log_message, exc)
68
+ return None