praisonai-bot 0.0.2__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.
- praisonai_bot-0.0.2/PKG-INFO +50 -0
- praisonai_bot-0.0.2/README.md +11 -0
- praisonai_bot-0.0.2/praisonai_bot/__init__.py +24 -0
- praisonai_bot-0.0.2/praisonai_bot/__main__.py +18 -0
- praisonai_bot-0.0.2/praisonai_bot/_async_bridge.py +279 -0
- praisonai_bot-0.0.2/praisonai_bot/_bootstrap.py +37 -0
- praisonai_bot-0.0.2/praisonai_bot/_code_bridge.py +28 -0
- praisonai_bot-0.0.2/praisonai_bot/_lockmap.py +142 -0
- praisonai_bot-0.0.2/praisonai_bot/_registry.py +22 -0
- praisonai_bot-0.0.2/praisonai_bot/_version.py +1 -0
- praisonai_bot-0.0.2/praisonai_bot/_wrapper_bridge.py +31 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/__init__.py +210 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_ack.py +99 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_admission.py +283 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_approval.py +168 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_approval_base.py +176 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_approval_store.py +402 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_chunk.py +168 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_commands.py +1328 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_config_schema.py +584 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_correlation.py +131 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_dead_targets.py +339 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_debounce.py +121 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_defaults.py +322 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_delivery.py +652 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_delivery_control_store.py +405 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_discord_approval.py +409 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_dlq.py +559 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_durable_adapter.py +161 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_email_utils.py +128 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_http_approval.py +253 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_identity.py +199 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_ingress.py +447 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_media.py +309 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_metrics.py +203 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_mirror.py +134 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_outbound_media.py +430 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_outbound_messenger.py +282 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_outbound_resilience.py +146 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_outbox.py +595 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_pairing_ui.py +263 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_presentation_approval.py +629 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_presentation_approval_backend.py +153 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_presentation_renderer.py +415 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_protocol_mixin.py +694 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_rate_limit.py +266 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_registry.py +249 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_relay_adapter.py +231 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_reliability.py +190 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_reset_policy.py +129 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_resilience.py +537 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_run_control.py +379 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_session.py +1780 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_slack_approval.py +431 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_status.py +256 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_streaming.py +451 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_telegram_approval.py +427 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_typing.py +203 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_typing_indicator.py +116 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_unknown_user.py +172 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_webhook_approval.py +249 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/_whatsapp_web_adapter.py +333 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/agentmail.py +957 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/bot.py +378 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/botos.py +1541 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/delivery.py +814 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/discord.py +935 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/email.py +652 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/linear.py +555 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/media.py +78 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/slack.py +1103 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/telegram.py +1868 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/webhook_security.py +203 -0
- praisonai_bot-0.0.2/praisonai_bot/bots/whatsapp.py +1173 -0
- praisonai_bot-0.0.2/praisonai_bot/claw/__init__.py +1 -0
- praisonai_bot-0.0.2/praisonai_bot/claw/default_app.py +130 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/__init__.py +0 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/app.py +58 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/approval_backends.py +78 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/__init__.py +0 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/bot.py +816 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/claw.py +121 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/gateway.py +807 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/identity.py +110 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/kanban.py +452 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/mint_link.py +81 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/onboard.py +48 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/commands/pairing.py +169 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/features/__init__.py +0 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/features/approval.py +170 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/features/bots_cli.py +1359 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/features/gateway.py +711 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/features/onboard.py +944 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/features/recipe_gateway.py +41 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/output/__init__.py +1 -0
- praisonai_bot-0.0.2/praisonai_bot/cli/output/console.py +67 -0
- praisonai_bot-0.0.2/praisonai_bot/daemon/__init__.py +71 -0
- praisonai_bot-0.0.2/praisonai_bot/daemon/launchd.py +138 -0
- praisonai_bot-0.0.2/praisonai_bot/daemon/systemd.py +123 -0
- praisonai_bot-0.0.2/praisonai_bot/daemon/windows.py +204 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/__init__.py +69 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/auth.py +125 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/client.py +435 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/cookie_auth.py +241 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/exec_approval.py +1026 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/forensics.py +201 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/gateway_approval.py +143 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/health_monitor.py +341 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/home_channels.py +271 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/kanban_dispatcher.py +807 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/magic_link.py +390 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/origin_check.py +77 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/pairing.py +375 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/pairing_routes.py +172 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/port_utils.py +216 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/push_channels.py +224 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/push_delivery.py +281 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/push_polling.py +306 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/push_presence.py +235 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/rate_limiter.py +326 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/redis_pubsub.py +253 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/server.py +5599 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/supervisor.py +376 -0
- praisonai_bot-0.0.2/praisonai_bot/gateway/unicode_utils.py +167 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/__init__.py +0 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/bridges/__init__.py +1 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/bridges/kanban_bridge.py +117 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/context_files.py +130 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/gateway_host.py +52 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/host_app.py +410 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/pages/__init__.py +8 -0
- praisonai_bot-0.0.2/praisonai_bot/integration/pages/bot_health.py +21 -0
- praisonai_bot-0.0.2/praisonai_bot/kanban/__init__.py +14 -0
- praisonai_bot-0.0.2/praisonai_bot/kanban/models.py +308 -0
- praisonai_bot-0.0.2/praisonai_bot/kanban/paths.py +71 -0
- praisonai_bot-0.0.2/praisonai_bot/kanban/sqlite_store.py +1174 -0
- praisonai_bot-0.0.2/praisonai_bot/tools/__init__.py +0 -0
- praisonai_bot-0.0.2/praisonai_bot/tools/audio.py +268 -0
- praisonai_bot-0.0.2/praisonai_bot.egg-info/PKG-INFO +50 -0
- praisonai_bot-0.0.2/praisonai_bot.egg-info/SOURCES.txt +144 -0
- praisonai_bot-0.0.2/praisonai_bot.egg-info/dependency_links.txt +1 -0
- praisonai_bot-0.0.2/praisonai_bot.egg-info/entry_points.txt +11 -0
- praisonai_bot-0.0.2/praisonai_bot.egg-info/requires.txt +32 -0
- praisonai_bot-0.0.2/praisonai_bot.egg-info/top_level.txt +1 -0
- praisonai_bot-0.0.2/pyproject.toml +73 -0
- praisonai_bot-0.0.2/setup.cfg +4 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: praisonai-bot
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper.
|
|
5
|
+
Author: Mervin Praison
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://docs.praison.ai
|
|
8
|
+
Project-URL: Repository, https://github.com/mervinpraison/PraisonAI
|
|
9
|
+
Requires-Python: <3.15,>=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: praisonaiagents>=1.6.117
|
|
12
|
+
Requires-Dist: rich>=13.7
|
|
13
|
+
Requires-Dist: typer>=0.12.0
|
|
14
|
+
Requires-Dist: click>=8.4.2
|
|
15
|
+
Requires-Dist: PyYAML>=6.0
|
|
16
|
+
Requires-Dist: python-dotenv>=0.19.0
|
|
17
|
+
Requires-Dist: pydantic>=2.0.0
|
|
18
|
+
Requires-Dist: aiohttp>=3.8.0
|
|
19
|
+
Provides-Extra: gateway
|
|
20
|
+
Requires-Dist: fastapi>=0.115.0; extra == "gateway"
|
|
21
|
+
Requires-Dist: uvicorn>=0.34.0; extra == "gateway"
|
|
22
|
+
Requires-Dist: sse-starlette>=1.6.0; extra == "gateway"
|
|
23
|
+
Requires-Dist: websockets>=12.0; extra == "gateway"
|
|
24
|
+
Requires-Dist: watchdog>=3.0.0; extra == "gateway"
|
|
25
|
+
Requires-Dist: redis>=5.0.0; extra == "gateway"
|
|
26
|
+
Provides-Extra: bot
|
|
27
|
+
Requires-Dist: python-telegram-bot>=20.0; extra == "bot"
|
|
28
|
+
Requires-Dist: discord.py>=2.0.0; extra == "bot"
|
|
29
|
+
Requires-Dist: slack_sdk>=3.0.0; extra == "bot"
|
|
30
|
+
Requires-Dist: slack-bolt>=1.18.0; extra == "bot"
|
|
31
|
+
Requires-Dist: brotlicffi>=1.0.0; extra == "bot"
|
|
32
|
+
Requires-Dist: psutil>=5.9.0; extra == "bot"
|
|
33
|
+
Requires-Dist: ddgs>=9.0.0; extra == "bot"
|
|
34
|
+
Provides-Extra: bot-whatsapp-web
|
|
35
|
+
Requires-Dist: neonize>=0.3.14; extra == "bot-whatsapp-web"
|
|
36
|
+
Requires-Dist: qrcode>=7.0; extra == "bot-whatsapp-web"
|
|
37
|
+
Provides-Extra: all
|
|
38
|
+
Requires-Dist: praisonai-bot[bot,gateway]; extra == "all"
|
|
39
|
+
|
|
40
|
+
# praisonai-bot
|
|
41
|
+
|
|
42
|
+
Bots, gateway, and channel CLI for PraisonAI.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install praisonai-bot[gateway,bot]
|
|
46
|
+
praisonai-bot gateway start --config gateway.yaml
|
|
47
|
+
praisonai-bot bot start --config bot.yaml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Protocols live in `praisonaiagents`; this package holds platform adapters and the WebSocket gateway server.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# praisonai-bot
|
|
2
|
+
|
|
3
|
+
Bots, gateway, and channel CLI for PraisonAI.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install praisonai-bot[gateway,bot]
|
|
7
|
+
praisonai-bot gateway start --config gateway.yaml
|
|
8
|
+
praisonai-bot bot start --config bot.yaml
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Protocols live in `praisonaiagents`; this package holds platform adapters and the WebSocket gateway server.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""PraisonAI bots and gateway runtime package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__version__ = "0.0.1"
|
|
6
|
+
|
|
7
|
+
_LAZY_EXPORTS = {
|
|
8
|
+
"Bot": ("praisonai_bot.bots.bot", "Bot"),
|
|
9
|
+
"BotOS": ("praisonai_bot.bots.botos", "BotOS"),
|
|
10
|
+
"WebSocketGateway": ("praisonai_bot.gateway.server", "WebSocketGateway"),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str):
|
|
15
|
+
if name in _LAZY_EXPORTS:
|
|
16
|
+
mod_path, attr = _LAZY_EXPORTS[name]
|
|
17
|
+
import importlib
|
|
18
|
+
|
|
19
|
+
mod = importlib.import_module(mod_path)
|
|
20
|
+
return getattr(mod, attr)
|
|
21
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["Bot", "BotOS", "WebSocketGateway", "__version__"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Console entry: ``praisonai-bot``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv: list[str] | None = None) -> None:
|
|
9
|
+
from praisonai_bot.cli.app import app
|
|
10
|
+
|
|
11
|
+
args = argv if argv is not None else sys.argv[1:]
|
|
12
|
+
if not args:
|
|
13
|
+
args = ["--help"]
|
|
14
|
+
app(args=args, prog_name="praisonai-bot")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async bridge module - single source of truth for running coroutines synchronously.
|
|
3
|
+
|
|
4
|
+
This module provides a safe way to run async functions from sync contexts,
|
|
5
|
+
handling nested event loop scenarios without creating a new event loop
|
|
6
|
+
on every call (which is expensive and breaks multi-agent workflows).
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
import atexit
|
|
10
|
+
import contextlib
|
|
11
|
+
import contextvars
|
|
12
|
+
import os
|
|
13
|
+
import threading
|
|
14
|
+
import concurrent.futures
|
|
15
|
+
from concurrent.futures import CancelledError as FutureCancelledError, Future
|
|
16
|
+
from typing import Awaitable, Iterator, Optional, TypeVar
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
_DEFAULT_TIMEOUT = float(os.environ.get("PRAISONAI_RUN_SYNC_TIMEOUT", "300"))
|
|
21
|
+
|
|
22
|
+
class AsyncBridge:
|
|
23
|
+
"""Per-instance async runner. The module-level `run_sync()` keeps the
|
|
24
|
+
historical shared default; embedders/services should construct their own."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
28
|
+
self._thread: threading.Thread | None = None
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
# Permanently-closed flag. Set by ``shutdown(permanent=True)`` for
|
|
31
|
+
# scope-owned bridges so a leaked context (e.g. an ``asyncio.Task`` that
|
|
32
|
+
# copied the ContextVar inside a ``scoped_bridge`` block) cannot silently
|
|
33
|
+
# resurrect an orphaned loop+thread after the scope exits. The shared
|
|
34
|
+
# default is never marked permanent so its only teardown is the atexit
|
|
35
|
+
# hook at process exit.
|
|
36
|
+
self._closed = False
|
|
37
|
+
# Identity of the thread currently holding ``self._lock``. Set under the
|
|
38
|
+
# lock by ``get()``/``submit()`` so ``_spawn_locked()`` can verify the
|
|
39
|
+
# *caller* (not merely *someone*) owns the lock.
|
|
40
|
+
self._lock_owner: int | None = None
|
|
41
|
+
|
|
42
|
+
def _spawn_locked(self) -> asyncio.AbstractEventLoop:
|
|
43
|
+
"""Create the loop+thread; caller must hold ``self._lock``.
|
|
44
|
+
|
|
45
|
+
The thread is marked ``daemon=True`` so that short-lived scripts
|
|
46
|
+
(e.g. CLI commands, smoke tests) exit cleanly without waiting on
|
|
47
|
+
the background loop to be shut down explicitly. Long-running
|
|
48
|
+
server processes should call :meth:`shutdown` explicitly to cancel
|
|
49
|
+
in-flight tasks before process exit.
|
|
50
|
+
"""
|
|
51
|
+
# Enforce the "caller must hold the lock" contract. We compare the
|
|
52
|
+
# recorded lock owner against the current thread so the check detects
|
|
53
|
+
# both an unlocked lock *and* a lock held by a different thread (a bare
|
|
54
|
+
# non-blocking acquire could only prove the lock was free for *anyone*).
|
|
55
|
+
if self._lock_owner != threading.get_ident():
|
|
56
|
+
raise AssertionError(
|
|
57
|
+
"_spawn_locked() must be called while holding self._lock"
|
|
58
|
+
)
|
|
59
|
+
# Refuse to resurrect a permanently-closed (scope-owned) bridge. This
|
|
60
|
+
# guards the case where a context copied inside ``scoped_bridge`` outlives
|
|
61
|
+
# the ``with`` block and later tries to run work through the now-shut-down
|
|
62
|
+
# bridge: instead of silently starting an orphaned loop+thread that no
|
|
63
|
+
# scope or atexit hook owns, fail loudly.
|
|
64
|
+
if self._closed:
|
|
65
|
+
raise RuntimeError(
|
|
66
|
+
"AsyncBridge has been shut down and cannot be reused; "
|
|
67
|
+
"this usually means a context outlived its scoped_bridge() block"
|
|
68
|
+
)
|
|
69
|
+
if self._loop is None or self._loop.is_closed():
|
|
70
|
+
self._loop = asyncio.new_event_loop()
|
|
71
|
+
self._thread = threading.Thread(
|
|
72
|
+
target=self._loop.run_forever,
|
|
73
|
+
name="praisonai-async",
|
|
74
|
+
daemon=True,
|
|
75
|
+
)
|
|
76
|
+
self._thread.start()
|
|
77
|
+
return self._loop
|
|
78
|
+
|
|
79
|
+
def get(self) -> asyncio.AbstractEventLoop:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._lock_owner = threading.get_ident()
|
|
82
|
+
try:
|
|
83
|
+
return self._spawn_locked()
|
|
84
|
+
finally:
|
|
85
|
+
self._lock_owner = None
|
|
86
|
+
|
|
87
|
+
def submit(self, coro):
|
|
88
|
+
"""Atomically (re)spawn loop if needed and submit coro. Returns concurrent.futures.Future."""
|
|
89
|
+
with self._lock:
|
|
90
|
+
self._lock_owner = threading.get_ident()
|
|
91
|
+
try:
|
|
92
|
+
loop = self._spawn_locked()
|
|
93
|
+
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
94
|
+
finally:
|
|
95
|
+
self._lock_owner = None
|
|
96
|
+
|
|
97
|
+
def run_sync(self, coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
|
|
98
|
+
try:
|
|
99
|
+
asyncio.get_running_loop()
|
|
100
|
+
except RuntimeError:
|
|
101
|
+
pass
|
|
102
|
+
else:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"run_sync() cannot be called from a running event loop"
|
|
105
|
+
)
|
|
106
|
+
fut = self.submit(coro)
|
|
107
|
+
try:
|
|
108
|
+
return fut.result(timeout=timeout)
|
|
109
|
+
except (TimeoutError, concurrent.futures.TimeoutError):
|
|
110
|
+
fut.cancel()
|
|
111
|
+
try:
|
|
112
|
+
fut.exception(timeout=1.0)
|
|
113
|
+
except (
|
|
114
|
+
TimeoutError,
|
|
115
|
+
concurrent.futures.TimeoutError,
|
|
116
|
+
asyncio.CancelledError,
|
|
117
|
+
FutureCancelledError,
|
|
118
|
+
):
|
|
119
|
+
pass
|
|
120
|
+
raise
|
|
121
|
+
except BaseException:
|
|
122
|
+
fut.cancel()
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
def shutdown(self, timeout: float = 5.0, *, permanent: bool = False) -> None:
|
|
126
|
+
"""Tear down the loop+thread.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
timeout: Seconds to wait for in-flight tasks to cancel and the
|
|
130
|
+
background thread to join.
|
|
131
|
+
permanent: When ``True`` the bridge is marked closed and may not be
|
|
132
|
+
reused; a later ``run_sync``/``submit`` raises ``RuntimeError``
|
|
133
|
+
rather than silently resurrecting an orphaned loop. Used by
|
|
134
|
+
:func:`scoped_bridge` for the bridges it owns. The shared default
|
|
135
|
+
is shut down with ``permanent=False`` so it never poisons callers.
|
|
136
|
+
"""
|
|
137
|
+
# Snapshot loop and thread outside lock to avoid holding lock during wait
|
|
138
|
+
with self._lock:
|
|
139
|
+
if permanent:
|
|
140
|
+
self._closed = True
|
|
141
|
+
loop, thread = self._loop, self._thread
|
|
142
|
+
if loop is None:
|
|
143
|
+
return
|
|
144
|
+
# Clear references immediately to prevent new submissions
|
|
145
|
+
self._loop = None
|
|
146
|
+
self._thread = None
|
|
147
|
+
|
|
148
|
+
# Now do the actual shutdown without holding the lock
|
|
149
|
+
async def _cancel_all() -> None:
|
|
150
|
+
self_task = asyncio.current_task()
|
|
151
|
+
tasks = [t for t in asyncio.all_tasks(loop)
|
|
152
|
+
if not t.done() and t is not self_task]
|
|
153
|
+
for t in tasks:
|
|
154
|
+
t.cancel()
|
|
155
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
asyncio.run_coroutine_threadsafe(_cancel_all(), loop).result(timeout)
|
|
159
|
+
finally:
|
|
160
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
161
|
+
if thread is not None:
|
|
162
|
+
thread.join(timeout)
|
|
163
|
+
if not loop.is_closed():
|
|
164
|
+
loop.close()
|
|
165
|
+
|
|
166
|
+
# Backwards-compatible module-level shared default.
|
|
167
|
+
#
|
|
168
|
+
# The shared bridge's lifecycle is owned exclusively by the process (via the
|
|
169
|
+
# ``atexit`` hook registered on first use). It is intentionally NOT exposed as a
|
|
170
|
+
# public ``shutdown()`` on the module surface: in a multi-agent / multi-tenant
|
|
171
|
+
# process every ``run_sync()`` caller shares this single loop+thread, so allowing
|
|
172
|
+
# any one caller to tear it down would silently cancel in-flight work belonging
|
|
173
|
+
# to every other agent/session. Embedders that need explicit lifecycle control
|
|
174
|
+
# must construct and own their own :class:`AsyncBridge` instance and pass it via
|
|
175
|
+
# dependency injection.
|
|
176
|
+
|
|
177
|
+
# Single stable shared instance. It is created eagerly (cheap: no loop/thread is
|
|
178
|
+
# spawned until the first ``run_sync()`` call) so the reference never has to be
|
|
179
|
+
# rebound. Keeping the *same* object means callers that captured ``_BG`` always
|
|
180
|
+
# observe its live state, and only the process-exit hook tears it down.
|
|
181
|
+
_BG: AsyncBridge = AsyncBridge()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Context-scoped override. When set (via :func:`scoped_bridge`), callers in that
|
|
185
|
+
# context — and any code they transitively invoke, including async tasks that
|
|
186
|
+
# copy the context — resolve to the scoped bridge instead of the shared default.
|
|
187
|
+
# This lets server/gateway/managed callers bind a per-session loop+thread
|
|
188
|
+
# without rewriting internal modules that import ``run_sync`` directly.
|
|
189
|
+
_bridge_var: contextvars.ContextVar[Optional[AsyncBridge]] = contextvars.ContextVar(
|
|
190
|
+
"praisonai_async_bridge", default=None
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _default_bridge() -> AsyncBridge:
|
|
195
|
+
"""Resolve the active bridge: context-scoped override, else shared default."""
|
|
196
|
+
scoped = _bridge_var.get()
|
|
197
|
+
if scoped is not None:
|
|
198
|
+
return scoped
|
|
199
|
+
return _BG
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def current_bridge() -> AsyncBridge:
|
|
203
|
+
"""Return the :class:`AsyncBridge` active in the current context.
|
|
204
|
+
|
|
205
|
+
Returns the bridge bound by an enclosing :func:`scoped_bridge` block, or the
|
|
206
|
+
process-wide shared default when no scope is active. Prefer this over reaching
|
|
207
|
+
for the module-level ``_BG`` so embedders can override the bridge via
|
|
208
|
+
dependency injection.
|
|
209
|
+
"""
|
|
210
|
+
return _default_bridge()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@contextlib.contextmanager
|
|
214
|
+
def scoped_bridge(bridge: Optional[AsyncBridge] = None) -> Iterator[AsyncBridge]:
|
|
215
|
+
"""Bind a per-scope :class:`AsyncBridge` for the duration of the ``with`` block.
|
|
216
|
+
|
|
217
|
+
Server callers (``praisonai serve``, gateway, managed agents) can isolate a
|
|
218
|
+
session's sync→async work onto its own loop+thread so a stuck coroutine in one
|
|
219
|
+
session does not park the shared default loop for every other session::
|
|
220
|
+
|
|
221
|
+
with scoped_bridge() as bridge:
|
|
222
|
+
run_sync(some_coro()) # uses ``bridge`` instead of the global default
|
|
223
|
+
...
|
|
224
|
+
# bridge is shut down automatically on exit when created here.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
bridge: An existing bridge to bind. When ``None`` a fresh bridge is created
|
|
228
|
+
for the scope and shut down on exit. A caller-provided bridge is left
|
|
229
|
+
untouched (the caller owns its lifecycle).
|
|
230
|
+
"""
|
|
231
|
+
owns = bridge is None
|
|
232
|
+
if bridge is None:
|
|
233
|
+
bridge = AsyncBridge()
|
|
234
|
+
token = _bridge_var.set(bridge)
|
|
235
|
+
try:
|
|
236
|
+
yield bridge
|
|
237
|
+
finally:
|
|
238
|
+
_bridge_var.reset(token)
|
|
239
|
+
if owns:
|
|
240
|
+
# ``permanent=True`` poisons the bridge so a context that copied the
|
|
241
|
+
# ContextVar inside this block cannot resurrect it after we exit.
|
|
242
|
+
bridge.shutdown(permanent=True)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _shutdown_default() -> None:
|
|
246
|
+
"""Private process-exit hook: tear down the shared default bridge.
|
|
247
|
+
|
|
248
|
+
Registered with :mod:`atexit`. This is the ONLY sanctioned way the shared
|
|
249
|
+
default is shut down; it is deliberately absent from the public module
|
|
250
|
+
surface so a single caller cannot terminate async work for the whole
|
|
251
|
+
process. Embedders needing explicit lifecycle control own their own
|
|
252
|
+
:class:`AsyncBridge` instance.
|
|
253
|
+
"""
|
|
254
|
+
_BG.shutdown()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
atexit.register(_shutdown_default)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
|
|
261
|
+
"""
|
|
262
|
+
Run a coroutine synchronously using the background loop.
|
|
263
|
+
|
|
264
|
+
IMPORTANT: This function cannot be called from within a running event loop
|
|
265
|
+
as it would cause deadlock. Use 'await coro' directly from async contexts.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
coro: The coroutine to run
|
|
269
|
+
timeout: Maximum time to wait for completion (seconds)
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The result of the coroutine
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
RuntimeError: If called from within a running event loop
|
|
276
|
+
TimeoutError: If timeout is exceeded
|
|
277
|
+
Any exception raised by the coroutine
|
|
278
|
+
"""
|
|
279
|
+
return _default_bridge().run_sync(coro, timeout=timeout)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Monorepo bootstrap for ``praisonai_bot`` and optional ``praisonai_code``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ensure_praisonai_bot() -> None:
|
|
10
|
+
"""Ensure ``praisonai_bot`` is importable in monorepo dev layouts."""
|
|
11
|
+
try:
|
|
12
|
+
import praisonai_bot # noqa: F401
|
|
13
|
+
return
|
|
14
|
+
except ImportError:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
here = Path(__file__).resolve().parents[1] # .../src/praisonai-bot
|
|
18
|
+
if (here / "praisonai_bot").is_dir():
|
|
19
|
+
root = str(here)
|
|
20
|
+
if root not in sys.path:
|
|
21
|
+
sys.path.insert(0, root)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def ensure_praisonai_code() -> None:
|
|
25
|
+
"""Optional code-tier imports (tool resolver, llm env) when co-installed."""
|
|
26
|
+
try:
|
|
27
|
+
import praisonai_code # noqa: F401
|
|
28
|
+
return
|
|
29
|
+
except ImportError:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
bot_src = Path(__file__).resolve().parents[1]
|
|
33
|
+
code_src = bot_src.parent / "praisonai-code"
|
|
34
|
+
if (code_src / "praisonai_code").is_dir():
|
|
35
|
+
root = str(code_src)
|
|
36
|
+
if root not in sys.path:
|
|
37
|
+
sys.path.insert(0, root)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Lazy access from praisonai-bot to optional praisonai-code modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from praisonai_bot._bootstrap import ensure_praisonai_code
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def code_available() -> bool:
|
|
12
|
+
ensure_praisonai_code()
|
|
13
|
+
try:
|
|
14
|
+
import praisonai_code # noqa: F401
|
|
15
|
+
return True
|
|
16
|
+
except ImportError:
|
|
17
|
+
return False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def import_code_module(name: str) -> Any:
|
|
21
|
+
ensure_praisonai_code()
|
|
22
|
+
try:
|
|
23
|
+
return importlib.import_module(name)
|
|
24
|
+
except ImportError as exc:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
f"Optional code module {name!r} requires praisonai-code. "
|
|
27
|
+
"Install with: pip install praisonai-code"
|
|
28
|
+
) from exc
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Per-key asyncio.Lock map with bounded LRU eviction and optional TTL.
|
|
2
|
+
|
|
3
|
+
Use this anywhere you need 'a lock per (user_id|chat_id|key)' instead of
|
|
4
|
+
re-implementing the dict-of-Lock pattern.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import asyncio
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import weakref
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from typing import Hashable, Dict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LockMap:
|
|
16
|
+
"""Asyncio-safe per-key asyncio.Lock map with automatic cleanup.
|
|
17
|
+
|
|
18
|
+
Provides a lock per key with LRU eviction and TTL-based cleanup to prevent
|
|
19
|
+
unbounded memory growth in long-running applications.
|
|
20
|
+
|
|
21
|
+
Buckets are keyed by event loop (one loop per OS thread). Structural
|
|
22
|
+
mutations of the bucket/loop maps are guarded by a ``threading.Lock`` so
|
|
23
|
+
the same instance can be safely shared across multiple OS threads, each
|
|
24
|
+
running its own event loop. Per-loop buckets are only ever touched from
|
|
25
|
+
their owning loop, so intra-loop access remains lock-free.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, max_entries: int = 10_000, ttl_seconds: float = 3600.0):
|
|
29
|
+
"""Initialize LockMap with bounds.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
max_entries: Maximum number of locks to cache (LRU eviction)
|
|
33
|
+
ttl_seconds: Time-to-live for unused locks in seconds
|
|
34
|
+
"""
|
|
35
|
+
# Bucket locks by event loop to avoid cross-loop issues
|
|
36
|
+
self._buckets: Dict[int, "OrderedDict[Hashable, tuple[asyncio.Lock, float]]"] = {}
|
|
37
|
+
self._loop_refs: Dict[int, weakref.ref] = {}
|
|
38
|
+
# Guards structural mutations of _buckets / _loop_refs across OS threads.
|
|
39
|
+
self._struct_lock = threading.Lock()
|
|
40
|
+
self._max = max_entries
|
|
41
|
+
self._ttl = ttl_seconds
|
|
42
|
+
|
|
43
|
+
def get(self, key: Hashable) -> asyncio.Lock:
|
|
44
|
+
"""Get or create a lock for the given key.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
key: The key to get a lock for
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
asyncio.Lock for the key
|
|
51
|
+
"""
|
|
52
|
+
# Get the current running loop
|
|
53
|
+
loop = asyncio.get_running_loop()
|
|
54
|
+
loop_id = id(loop)
|
|
55
|
+
|
|
56
|
+
# Fast path: a bucket for this loop already exists. The bucket is only
|
|
57
|
+
# ever mutated by its owning loop's thread, so a single dict read is
|
|
58
|
+
# safe under CPython's GIL without taking the cross-thread struct lock.
|
|
59
|
+
bucket = self._buckets.get(loop_id)
|
|
60
|
+
if bucket is None:
|
|
61
|
+
# Slow path: structural mutation shared across threads needs the lock.
|
|
62
|
+
with self._struct_lock:
|
|
63
|
+
# Clean up dead loops (only on first-touch to avoid per-call cost)
|
|
64
|
+
self._cleanup_dead_loops_locked()
|
|
65
|
+
|
|
66
|
+
# Re-check after acquiring the lock (another thread may have raced).
|
|
67
|
+
bucket = self._buckets.get(loop_id)
|
|
68
|
+
if bucket is None:
|
|
69
|
+
bucket = OrderedDict()
|
|
70
|
+
self._buckets[loop_id] = bucket
|
|
71
|
+
self._loop_refs[loop_id] = weakref.ref(loop)
|
|
72
|
+
|
|
73
|
+
# From here on only this loop's owning thread touches the bucket.
|
|
74
|
+
now = time.monotonic()
|
|
75
|
+
|
|
76
|
+
entry = bucket.get(key)
|
|
77
|
+
if entry is not None:
|
|
78
|
+
lock, _ = entry
|
|
79
|
+
# Move to end (most recently used) and update timestamp
|
|
80
|
+
bucket.move_to_end(key)
|
|
81
|
+
bucket[key] = (lock, now)
|
|
82
|
+
return lock
|
|
83
|
+
|
|
84
|
+
# Create new lock + insert + LRU/TTL evict
|
|
85
|
+
lock = asyncio.Lock()
|
|
86
|
+
bucket[key] = (lock, now)
|
|
87
|
+
self._evict_stale_from_bucket(bucket, now)
|
|
88
|
+
return lock
|
|
89
|
+
|
|
90
|
+
def _evict_stale_from_bucket(self, bucket: "OrderedDict", now: float) -> None:
|
|
91
|
+
"""Evict expired and excess entries from a bucket."""
|
|
92
|
+
# Expire by TTL (only if not currently locked)
|
|
93
|
+
expired = [
|
|
94
|
+
k for k, (lock, ts) in bucket.items()
|
|
95
|
+
if (now - ts) > self._ttl and not lock.locked()
|
|
96
|
+
]
|
|
97
|
+
for k in expired:
|
|
98
|
+
bucket.pop(k, None)
|
|
99
|
+
|
|
100
|
+
# Cap by LRU (don't evict locks currently held)
|
|
101
|
+
seen = set()
|
|
102
|
+
while len(bucket) > self._max:
|
|
103
|
+
k, (lock, _) = next(iter(bucket.items()))
|
|
104
|
+
if k in seen:
|
|
105
|
+
# All remaining locks are held; give up to avoid infinite loop
|
|
106
|
+
break
|
|
107
|
+
if lock.locked():
|
|
108
|
+
# Don't evict locks currently held; bump them to the end
|
|
109
|
+
bucket.move_to_end(k)
|
|
110
|
+
seen.add(k)
|
|
111
|
+
# Continue trying to evict other unlocked entries
|
|
112
|
+
continue
|
|
113
|
+
bucket.popitem(last=False)
|
|
114
|
+
|
|
115
|
+
def _cleanup_dead_loops_locked(self) -> None:
|
|
116
|
+
"""Remove buckets for event loops that no longer exist.
|
|
117
|
+
|
|
118
|
+
Caller must hold ``self._struct_lock``.
|
|
119
|
+
"""
|
|
120
|
+
dead_loops = [
|
|
121
|
+
loop_id
|
|
122
|
+
for loop_id, ref in self._loop_refs.items()
|
|
123
|
+
if (loop := ref()) is None or loop.is_closed()
|
|
124
|
+
]
|
|
125
|
+
for loop_id in dead_loops:
|
|
126
|
+
self._loop_refs.pop(loop_id, None)
|
|
127
|
+
self._buckets.pop(loop_id, None)
|
|
128
|
+
|
|
129
|
+
def drop(self, key: Hashable) -> None:
|
|
130
|
+
"""Manually remove a lock for the given key."""
|
|
131
|
+
# Get the current running loop
|
|
132
|
+
loop = asyncio.get_running_loop()
|
|
133
|
+
loop_id = id(loop)
|
|
134
|
+
with self._struct_lock:
|
|
135
|
+
bucket = self._buckets.get(loop_id)
|
|
136
|
+
if bucket is not None:
|
|
137
|
+
bucket.pop(key, None)
|
|
138
|
+
|
|
139
|
+
def size(self) -> int:
|
|
140
|
+
"""Return current number of cached locks across all buckets."""
|
|
141
|
+
with self._struct_lock:
|
|
142
|
+
return sum(len(bucket) for bucket in self._buckets.values())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Plugin registry base for praisonai-bot (lazy code-bridge import)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from praisonai_code._registry import PluginRegistry, create_lazy_getattr
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
__all__ = ["PluginRegistry", "create_lazy_getattr", "logger"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __getattr__(name: str):
|
|
17
|
+
if name in ("PluginRegistry", "create_lazy_getattr"):
|
|
18
|
+
from praisonai_bot._code_bridge import import_code_module
|
|
19
|
+
|
|
20
|
+
registry = import_code_module("praisonai_code._registry")
|
|
21
|
+
return getattr(registry, name)
|
|
22
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Lazy access from praisonai-bot to optional praisonai wrapper modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def wrapper_available() -> bool:
|
|
10
|
+
try:
|
|
11
|
+
import praisonai # noqa: F401
|
|
12
|
+
return True
|
|
13
|
+
except ImportError:
|
|
14
|
+
return False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def import_wrapper_module(name: str) -> Any:
|
|
18
|
+
if not wrapper_available():
|
|
19
|
+
raise ImportError(
|
|
20
|
+
f"Optional wrapper module {name!r} requires the praisonai package. "
|
|
21
|
+
"Install with: pip install praisonai"
|
|
22
|
+
)
|
|
23
|
+
return importlib.import_module(name)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def optional_wrapper_attr(module: str, attr: str, default: Any = None) -> Any:
|
|
27
|
+
try:
|
|
28
|
+
mod = import_wrapper_module(module)
|
|
29
|
+
return getattr(mod, attr)
|
|
30
|
+
except ImportError:
|
|
31
|
+
return default
|