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.
Files changed (146) hide show
  1. praisonai_bot-0.0.2/PKG-INFO +50 -0
  2. praisonai_bot-0.0.2/README.md +11 -0
  3. praisonai_bot-0.0.2/praisonai_bot/__init__.py +24 -0
  4. praisonai_bot-0.0.2/praisonai_bot/__main__.py +18 -0
  5. praisonai_bot-0.0.2/praisonai_bot/_async_bridge.py +279 -0
  6. praisonai_bot-0.0.2/praisonai_bot/_bootstrap.py +37 -0
  7. praisonai_bot-0.0.2/praisonai_bot/_code_bridge.py +28 -0
  8. praisonai_bot-0.0.2/praisonai_bot/_lockmap.py +142 -0
  9. praisonai_bot-0.0.2/praisonai_bot/_registry.py +22 -0
  10. praisonai_bot-0.0.2/praisonai_bot/_version.py +1 -0
  11. praisonai_bot-0.0.2/praisonai_bot/_wrapper_bridge.py +31 -0
  12. praisonai_bot-0.0.2/praisonai_bot/bots/__init__.py +210 -0
  13. praisonai_bot-0.0.2/praisonai_bot/bots/_ack.py +99 -0
  14. praisonai_bot-0.0.2/praisonai_bot/bots/_admission.py +283 -0
  15. praisonai_bot-0.0.2/praisonai_bot/bots/_approval.py +168 -0
  16. praisonai_bot-0.0.2/praisonai_bot/bots/_approval_base.py +176 -0
  17. praisonai_bot-0.0.2/praisonai_bot/bots/_approval_store.py +402 -0
  18. praisonai_bot-0.0.2/praisonai_bot/bots/_chunk.py +168 -0
  19. praisonai_bot-0.0.2/praisonai_bot/bots/_commands.py +1328 -0
  20. praisonai_bot-0.0.2/praisonai_bot/bots/_config_schema.py +584 -0
  21. praisonai_bot-0.0.2/praisonai_bot/bots/_correlation.py +131 -0
  22. praisonai_bot-0.0.2/praisonai_bot/bots/_dead_targets.py +339 -0
  23. praisonai_bot-0.0.2/praisonai_bot/bots/_debounce.py +121 -0
  24. praisonai_bot-0.0.2/praisonai_bot/bots/_defaults.py +322 -0
  25. praisonai_bot-0.0.2/praisonai_bot/bots/_delivery.py +652 -0
  26. praisonai_bot-0.0.2/praisonai_bot/bots/_delivery_control_store.py +405 -0
  27. praisonai_bot-0.0.2/praisonai_bot/bots/_discord_approval.py +409 -0
  28. praisonai_bot-0.0.2/praisonai_bot/bots/_dlq.py +559 -0
  29. praisonai_bot-0.0.2/praisonai_bot/bots/_durable_adapter.py +161 -0
  30. praisonai_bot-0.0.2/praisonai_bot/bots/_email_utils.py +128 -0
  31. praisonai_bot-0.0.2/praisonai_bot/bots/_http_approval.py +253 -0
  32. praisonai_bot-0.0.2/praisonai_bot/bots/_identity.py +199 -0
  33. praisonai_bot-0.0.2/praisonai_bot/bots/_ingress.py +447 -0
  34. praisonai_bot-0.0.2/praisonai_bot/bots/_media.py +309 -0
  35. praisonai_bot-0.0.2/praisonai_bot/bots/_metrics.py +203 -0
  36. praisonai_bot-0.0.2/praisonai_bot/bots/_mirror.py +134 -0
  37. praisonai_bot-0.0.2/praisonai_bot/bots/_outbound_media.py +430 -0
  38. praisonai_bot-0.0.2/praisonai_bot/bots/_outbound_messenger.py +282 -0
  39. praisonai_bot-0.0.2/praisonai_bot/bots/_outbound_resilience.py +146 -0
  40. praisonai_bot-0.0.2/praisonai_bot/bots/_outbox.py +595 -0
  41. praisonai_bot-0.0.2/praisonai_bot/bots/_pairing_ui.py +263 -0
  42. praisonai_bot-0.0.2/praisonai_bot/bots/_presentation_approval.py +629 -0
  43. praisonai_bot-0.0.2/praisonai_bot/bots/_presentation_approval_backend.py +153 -0
  44. praisonai_bot-0.0.2/praisonai_bot/bots/_presentation_renderer.py +415 -0
  45. praisonai_bot-0.0.2/praisonai_bot/bots/_protocol_mixin.py +694 -0
  46. praisonai_bot-0.0.2/praisonai_bot/bots/_rate_limit.py +266 -0
  47. praisonai_bot-0.0.2/praisonai_bot/bots/_registry.py +249 -0
  48. praisonai_bot-0.0.2/praisonai_bot/bots/_relay_adapter.py +231 -0
  49. praisonai_bot-0.0.2/praisonai_bot/bots/_reliability.py +190 -0
  50. praisonai_bot-0.0.2/praisonai_bot/bots/_reset_policy.py +129 -0
  51. praisonai_bot-0.0.2/praisonai_bot/bots/_resilience.py +537 -0
  52. praisonai_bot-0.0.2/praisonai_bot/bots/_run_control.py +379 -0
  53. praisonai_bot-0.0.2/praisonai_bot/bots/_session.py +1780 -0
  54. praisonai_bot-0.0.2/praisonai_bot/bots/_slack_approval.py +431 -0
  55. praisonai_bot-0.0.2/praisonai_bot/bots/_status.py +256 -0
  56. praisonai_bot-0.0.2/praisonai_bot/bots/_streaming.py +451 -0
  57. praisonai_bot-0.0.2/praisonai_bot/bots/_telegram_approval.py +427 -0
  58. praisonai_bot-0.0.2/praisonai_bot/bots/_typing.py +203 -0
  59. praisonai_bot-0.0.2/praisonai_bot/bots/_typing_indicator.py +116 -0
  60. praisonai_bot-0.0.2/praisonai_bot/bots/_unknown_user.py +172 -0
  61. praisonai_bot-0.0.2/praisonai_bot/bots/_webhook_approval.py +249 -0
  62. praisonai_bot-0.0.2/praisonai_bot/bots/_whatsapp_web_adapter.py +333 -0
  63. praisonai_bot-0.0.2/praisonai_bot/bots/agentmail.py +957 -0
  64. praisonai_bot-0.0.2/praisonai_bot/bots/bot.py +378 -0
  65. praisonai_bot-0.0.2/praisonai_bot/bots/botos.py +1541 -0
  66. praisonai_bot-0.0.2/praisonai_bot/bots/delivery.py +814 -0
  67. praisonai_bot-0.0.2/praisonai_bot/bots/discord.py +935 -0
  68. praisonai_bot-0.0.2/praisonai_bot/bots/email.py +652 -0
  69. praisonai_bot-0.0.2/praisonai_bot/bots/linear.py +555 -0
  70. praisonai_bot-0.0.2/praisonai_bot/bots/media.py +78 -0
  71. praisonai_bot-0.0.2/praisonai_bot/bots/slack.py +1103 -0
  72. praisonai_bot-0.0.2/praisonai_bot/bots/telegram.py +1868 -0
  73. praisonai_bot-0.0.2/praisonai_bot/bots/webhook_security.py +203 -0
  74. praisonai_bot-0.0.2/praisonai_bot/bots/whatsapp.py +1173 -0
  75. praisonai_bot-0.0.2/praisonai_bot/claw/__init__.py +1 -0
  76. praisonai_bot-0.0.2/praisonai_bot/claw/default_app.py +130 -0
  77. praisonai_bot-0.0.2/praisonai_bot/cli/__init__.py +0 -0
  78. praisonai_bot-0.0.2/praisonai_bot/cli/app.py +58 -0
  79. praisonai_bot-0.0.2/praisonai_bot/cli/approval_backends.py +78 -0
  80. praisonai_bot-0.0.2/praisonai_bot/cli/commands/__init__.py +0 -0
  81. praisonai_bot-0.0.2/praisonai_bot/cli/commands/bot.py +816 -0
  82. praisonai_bot-0.0.2/praisonai_bot/cli/commands/claw.py +121 -0
  83. praisonai_bot-0.0.2/praisonai_bot/cli/commands/gateway.py +807 -0
  84. praisonai_bot-0.0.2/praisonai_bot/cli/commands/identity.py +110 -0
  85. praisonai_bot-0.0.2/praisonai_bot/cli/commands/kanban.py +452 -0
  86. praisonai_bot-0.0.2/praisonai_bot/cli/commands/mint_link.py +81 -0
  87. praisonai_bot-0.0.2/praisonai_bot/cli/commands/onboard.py +48 -0
  88. praisonai_bot-0.0.2/praisonai_bot/cli/commands/pairing.py +169 -0
  89. praisonai_bot-0.0.2/praisonai_bot/cli/features/__init__.py +0 -0
  90. praisonai_bot-0.0.2/praisonai_bot/cli/features/approval.py +170 -0
  91. praisonai_bot-0.0.2/praisonai_bot/cli/features/bots_cli.py +1359 -0
  92. praisonai_bot-0.0.2/praisonai_bot/cli/features/gateway.py +711 -0
  93. praisonai_bot-0.0.2/praisonai_bot/cli/features/onboard.py +944 -0
  94. praisonai_bot-0.0.2/praisonai_bot/cli/features/recipe_gateway.py +41 -0
  95. praisonai_bot-0.0.2/praisonai_bot/cli/output/__init__.py +1 -0
  96. praisonai_bot-0.0.2/praisonai_bot/cli/output/console.py +67 -0
  97. praisonai_bot-0.0.2/praisonai_bot/daemon/__init__.py +71 -0
  98. praisonai_bot-0.0.2/praisonai_bot/daemon/launchd.py +138 -0
  99. praisonai_bot-0.0.2/praisonai_bot/daemon/systemd.py +123 -0
  100. praisonai_bot-0.0.2/praisonai_bot/daemon/windows.py +204 -0
  101. praisonai_bot-0.0.2/praisonai_bot/gateway/__init__.py +69 -0
  102. praisonai_bot-0.0.2/praisonai_bot/gateway/auth.py +125 -0
  103. praisonai_bot-0.0.2/praisonai_bot/gateway/client.py +435 -0
  104. praisonai_bot-0.0.2/praisonai_bot/gateway/cookie_auth.py +241 -0
  105. praisonai_bot-0.0.2/praisonai_bot/gateway/exec_approval.py +1026 -0
  106. praisonai_bot-0.0.2/praisonai_bot/gateway/forensics.py +201 -0
  107. praisonai_bot-0.0.2/praisonai_bot/gateway/gateway_approval.py +143 -0
  108. praisonai_bot-0.0.2/praisonai_bot/gateway/health_monitor.py +341 -0
  109. praisonai_bot-0.0.2/praisonai_bot/gateway/home_channels.py +271 -0
  110. praisonai_bot-0.0.2/praisonai_bot/gateway/kanban_dispatcher.py +807 -0
  111. praisonai_bot-0.0.2/praisonai_bot/gateway/magic_link.py +390 -0
  112. praisonai_bot-0.0.2/praisonai_bot/gateway/origin_check.py +77 -0
  113. praisonai_bot-0.0.2/praisonai_bot/gateway/pairing.py +375 -0
  114. praisonai_bot-0.0.2/praisonai_bot/gateway/pairing_routes.py +172 -0
  115. praisonai_bot-0.0.2/praisonai_bot/gateway/port_utils.py +216 -0
  116. praisonai_bot-0.0.2/praisonai_bot/gateway/push_channels.py +224 -0
  117. praisonai_bot-0.0.2/praisonai_bot/gateway/push_delivery.py +281 -0
  118. praisonai_bot-0.0.2/praisonai_bot/gateway/push_polling.py +306 -0
  119. praisonai_bot-0.0.2/praisonai_bot/gateway/push_presence.py +235 -0
  120. praisonai_bot-0.0.2/praisonai_bot/gateway/rate_limiter.py +326 -0
  121. praisonai_bot-0.0.2/praisonai_bot/gateway/redis_pubsub.py +253 -0
  122. praisonai_bot-0.0.2/praisonai_bot/gateway/server.py +5599 -0
  123. praisonai_bot-0.0.2/praisonai_bot/gateway/supervisor.py +376 -0
  124. praisonai_bot-0.0.2/praisonai_bot/gateway/unicode_utils.py +167 -0
  125. praisonai_bot-0.0.2/praisonai_bot/integration/__init__.py +0 -0
  126. praisonai_bot-0.0.2/praisonai_bot/integration/bridges/__init__.py +1 -0
  127. praisonai_bot-0.0.2/praisonai_bot/integration/bridges/kanban_bridge.py +117 -0
  128. praisonai_bot-0.0.2/praisonai_bot/integration/context_files.py +130 -0
  129. praisonai_bot-0.0.2/praisonai_bot/integration/gateway_host.py +52 -0
  130. praisonai_bot-0.0.2/praisonai_bot/integration/host_app.py +410 -0
  131. praisonai_bot-0.0.2/praisonai_bot/integration/pages/__init__.py +8 -0
  132. praisonai_bot-0.0.2/praisonai_bot/integration/pages/bot_health.py +21 -0
  133. praisonai_bot-0.0.2/praisonai_bot/kanban/__init__.py +14 -0
  134. praisonai_bot-0.0.2/praisonai_bot/kanban/models.py +308 -0
  135. praisonai_bot-0.0.2/praisonai_bot/kanban/paths.py +71 -0
  136. praisonai_bot-0.0.2/praisonai_bot/kanban/sqlite_store.py +1174 -0
  137. praisonai_bot-0.0.2/praisonai_bot/tools/__init__.py +0 -0
  138. praisonai_bot-0.0.2/praisonai_bot/tools/audio.py +268 -0
  139. praisonai_bot-0.0.2/praisonai_bot.egg-info/PKG-INFO +50 -0
  140. praisonai_bot-0.0.2/praisonai_bot.egg-info/SOURCES.txt +144 -0
  141. praisonai_bot-0.0.2/praisonai_bot.egg-info/dependency_links.txt +1 -0
  142. praisonai_bot-0.0.2/praisonai_bot.egg-info/entry_points.txt +11 -0
  143. praisonai_bot-0.0.2/praisonai_bot.egg-info/requires.txt +32 -0
  144. praisonai_bot-0.0.2/praisonai_bot.egg-info/top_level.txt +1 -0
  145. praisonai_bot-0.0.2/pyproject.toml +73 -0
  146. 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