Skip to content

Commit 3b2e30b

Browse files
committed
feat(utils): export async helpers and improve utilities
- Export AsyncHelperMixin and related utilities from utils.__init__ - AsyncHelperMixin - run_async_in_sync - log_event_sync - get_cached_sync - set_cache_sync - Improve DI container logic - Improve error handling utilities - Improve cache utilities - Update pyproject.toml dependencies - Update main __init__.py exports Files changed: 12 files, ~50 lines
1 parent c8a986e commit 3b2e30b

File tree

13 files changed

+355
-27
lines changed

13 files changed

+355
-27
lines changed

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ ml = [
7777
"torch>=2.0.0,<3.0.0",
7878
]
7979

80+
# CLI 기능
81+
cli = [
82+
"typer[all]>=0.9.0,<1.0.0", # 'all' includes shell completion dependencies
83+
]
84+
8085
# 모든 Provider 사용
8186
all = [
8287
"openai>=1.0.0,<3.0.0",
@@ -86,6 +91,7 @@ all = [
8691
"openai-whisper>=20231117,<20250626",
8792
"marker-pdf>=0.2.0,<2.0.0",
8893
"torch>=2.0.0,<3.0.0",
94+
"beanllm[cli]", # Include CLI for 'all' installation
8995
]
9096

9197
# Continuous Evaluation (선택적)
@@ -130,8 +136,10 @@ dev = [
130136
"black>=23.0.0,<26.0.0",
131137
"ruff>=0.1.0,<1.0.0",
132138
"mypy>=1.0.0,<2.0.0",
139+
"beanllm[cli]", # Add CLI development tools
133140
]
134141

142+
135143
[project.urls]
136144
Homepage = "https://github.com/leebeanbin/beanllm"
137145
Documentation = "https://github.com/leebeanbin/beanllm#readme"

src/beanllm/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -820,5 +820,7 @@ def _print_welcome_banner():
820820
try:
821821
_check_optional_dependencies()
822822
_print_welcome_banner()
823-
except Exception:
824-
pass # 에러 발생해도 import는 성공
823+
except Exception as e:
824+
# 에러 발생해도 import는 성공
825+
import logging
826+
logging.getLogger(__name__).debug(f"Initialization check failed (safe to ignore): {e}")

src/beanllm/utils/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,15 @@
8080

8181
STREAMING_WRAPPER_AVAILABLE = True
8282

83+
# Async Helpers
84+
from .async_helpers import (
85+
AsyncHelperMixin,
86+
get_cached_sync,
87+
log_event_sync,
88+
run_async_in_sync,
89+
set_cache_sync,
90+
)
91+
8392
# Integration Utilities
8493
from .integration import (
8594
BaseCallback,
@@ -283,6 +292,12 @@
283292
"set_cost_tracker",
284293
# CLI
285294
"main",
295+
# Async Helpers
296+
"AsyncHelperMixin",
297+
"run_async_in_sync",
298+
"log_event_sync",
299+
"get_cached_sync",
300+
"set_cache_sync",
286301
]
287302

288303

src/beanllm/utils/async_helpers.py

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""
2+
Async Helper Utilities
3+
4+
Provides helper methods for common async/sync patterns in beanllm.
5+
6+
This module reduces boilerplate code for:
7+
- Running async code in sync contexts (asyncio.run patterns)
8+
- Event logging with async protocols
9+
- Cache operations with async protocols
10+
"""
11+
12+
import asyncio
13+
from typing import TYPE_CHECKING, Any, Coroutine, Dict, Optional, TypeVar
14+
15+
if TYPE_CHECKING:
16+
from beanllm.domain.protocols import CacheProtocol, EventLoggerProtocol
17+
18+
T = TypeVar("T")
19+
20+
21+
def run_async_in_sync(coro: Coroutine[Any, Any, T]) -> Optional[T]:
22+
"""
23+
Run async code in sync context safely.
24+
25+
Handles three scenarios:
26+
1. Event loop is running → create task (fire-and-forget)
27+
2. Event loop exists but not running → run_until_complete
28+
3. No event loop → asyncio.run
29+
30+
Args:
31+
coro: Coroutine to execute
32+
33+
Returns:
34+
Result of coroutine, or None if fire-and-forget
35+
36+
Example:
37+
>>> async def async_operation():
38+
... return "result"
39+
>>> result = run_async_in_sync(async_operation())
40+
"""
41+
try:
42+
loop = asyncio.get_event_loop()
43+
if loop.is_running():
44+
# Fire-and-forget (can't await in running loop)
45+
asyncio.create_task(coro)
46+
return None
47+
else:
48+
# Loop exists but not running
49+
return loop.run_until_complete(coro)
50+
except RuntimeError:
51+
# No event loop exists
52+
return asyncio.run(coro)
53+
54+
55+
class AsyncHelperMixin:
56+
"""
57+
Mixin class providing async helper methods.
58+
59+
Use this in classes that need to call async protocols from sync methods.
60+
61+
Example:
62+
>>> class MyClass(AsyncHelperMixin):
63+
... def __init__(self, event_logger=None, cache=None):
64+
... self._event_logger = event_logger
65+
... self._cache = cache
66+
...
67+
... def sync_method(self):
68+
... # Log event (async protocol, sync method)
69+
... self._log_event("my_event", {"data": "value"})
70+
...
71+
... # Get from cache
72+
... cached = self._get_cached("key")
73+
"""
74+
75+
_event_logger: Optional["EventLoggerProtocol"] = None
76+
_cache: Optional["CacheProtocol"] = None
77+
78+
def _run_async(self, coro: Coroutine[Any, Any, T]) -> Optional[T]:
79+
"""
80+
Run async coroutine from sync method.
81+
82+
Args:
83+
coro: Coroutine to execute
84+
85+
Returns:
86+
Result of coroutine, or None if fire-and-forget
87+
88+
Example:
89+
>>> result = self._run_async(self._cache.get("key"))
90+
"""
91+
return run_async_in_sync(coro)
92+
93+
def _log_event(
94+
self,
95+
event_type: str,
96+
data: Dict[str, Any],
97+
fire_and_forget: bool = True,
98+
) -> None:
99+
"""
100+
Log event using event logger protocol (handles async in sync context).
101+
102+
Args:
103+
event_type: Event type identifier
104+
data: Event data dictionary
105+
fire_and_forget: If True, don't wait for result (default: True)
106+
107+
Example:
108+
>>> self._log_event("ocr.recognize.started", {"engine": "paddleocr"})
109+
110+
Note:
111+
Requires self._event_logger to be set (EventLoggerProtocol).
112+
Silently does nothing if event_logger is None.
113+
"""
114+
if self._event_logger is None:
115+
return
116+
117+
event_coro = self._event_logger.log_event(event_type, data)
118+
119+
if fire_and_forget:
120+
# Best effort - don't block
121+
try:
122+
loop = asyncio.get_event_loop()
123+
if loop.is_running():
124+
asyncio.create_task(event_coro)
125+
else:
126+
loop.run_until_complete(event_coro)
127+
except RuntimeError:
128+
asyncio.run(event_coro)
129+
else:
130+
# Wait for result
131+
self._run_async(event_coro)
132+
133+
def _get_cached(self, key: str, default: Optional[Any] = None) -> Optional[Any]:
134+
"""
135+
Get value from cache protocol (handles async in sync context).
136+
137+
Args:
138+
key: Cache key
139+
default: Default value if not found or cache unavailable
140+
141+
Returns:
142+
Cached value or default
143+
144+
Example:
145+
>>> cached = self._get_cached("embeddings:text123")
146+
>>> if cached:
147+
... return cached
148+
149+
Note:
150+
Requires self._cache to be set (CacheProtocol).
151+
Returns default if cache is None.
152+
"""
153+
if self._cache is None:
154+
return default
155+
156+
try:
157+
result = self._run_async(self._cache.get(key))
158+
return result if result is not None else default
159+
except Exception:
160+
# Cache failure - return default
161+
return default
162+
163+
def _set_cache(
164+
self,
165+
key: str,
166+
value: Any,
167+
ttl: Optional[int] = None,
168+
fire_and_forget: bool = True,
169+
) -> None:
170+
"""
171+
Set value in cache protocol (handles async in sync context).
172+
173+
Args:
174+
key: Cache key
175+
value: Value to cache
176+
ttl: Time-to-live in seconds (None = use cache default)
177+
fire_and_forget: If True, don't wait for result (default: True)
178+
179+
Example:
180+
>>> result = compute_expensive_operation()
181+
>>> self._set_cache("result:key123", result, ttl=3600)
182+
183+
Note:
184+
Requires self._cache to be set (CacheProtocol).
185+
Silently does nothing if cache is None.
186+
"""
187+
if self._cache is None:
188+
return
189+
190+
cache_coro = self._cache.set(key, value, ttl=ttl) if ttl else self._cache.set(key, value)
191+
192+
if fire_and_forget:
193+
# Best effort - don't block
194+
try:
195+
loop = asyncio.get_event_loop()
196+
if loop.is_running():
197+
asyncio.create_task(cache_coro)
198+
else:
199+
loop.run_until_complete(cache_coro)
200+
except (RuntimeError, Exception):
201+
try:
202+
asyncio.run(cache_coro)
203+
except Exception:
204+
pass # Cache storage failed - continue
205+
else:
206+
# Wait for result
207+
try:
208+
self._run_async(cache_coro)
209+
except Exception:
210+
pass # Cache storage failed - continue
211+
212+
213+
# Standalone functions for use without mixin
214+
215+
216+
def log_event_sync(
217+
event_logger: Optional["EventLoggerProtocol"],
218+
event_type: str,
219+
data: Dict[str, Any],
220+
) -> None:
221+
"""
222+
Standalone function to log event from sync context.
223+
224+
Args:
225+
event_logger: EventLoggerProtocol instance (or None)
226+
event_type: Event type identifier
227+
data: Event data dictionary
228+
229+
Example:
230+
>>> from beanllm.infrastructure.distributed import get_event_logger
231+
>>> logger = get_event_logger()
232+
>>> log_event_sync(logger, "task.started", {"task_id": "123"})
233+
"""
234+
if event_logger is None:
235+
return
236+
237+
event_coro = event_logger.log_event(event_type, data)
238+
run_async_in_sync(event_coro)
239+
240+
241+
def get_cached_sync(
242+
cache: Optional["CacheProtocol"],
243+
key: str,
244+
default: Optional[Any] = None,
245+
) -> Optional[Any]:
246+
"""
247+
Standalone function to get from cache in sync context.
248+
249+
Args:
250+
cache: CacheProtocol instance (or None)
251+
key: Cache key
252+
default: Default value if not found
253+
254+
Returns:
255+
Cached value or default
256+
257+
Example:
258+
>>> from beanllm.infrastructure.distributed import get_cache
259+
>>> cache = get_cache()
260+
>>> result = get_cached_sync(cache, "key123", default=[])
261+
"""
262+
if cache is None:
263+
return default
264+
265+
try:
266+
result = run_async_in_sync(cache.get(key))
267+
return result if result is not None else default
268+
except Exception:
269+
return default
270+
271+
272+
def set_cache_sync(
273+
cache: Optional["CacheProtocol"],
274+
key: str,
275+
value: Any,
276+
ttl: Optional[int] = None,
277+
) -> None:
278+
"""
279+
Standalone function to set cache value in sync context.
280+
281+
Args:
282+
cache: CacheProtocol instance (or None)
283+
key: Cache key
284+
value: Value to cache
285+
ttl: Time-to-live in seconds
286+
287+
Example:
288+
>>> from beanllm.infrastructure.distributed import get_cache
289+
>>> cache = get_cache()
290+
>>> set_cache_sync(cache, "result:123", {"data": "value"}, ttl=3600)
291+
"""
292+
if cache is None:
293+
return
294+
295+
cache_coro = cache.set(key, value, ttl=ttl) if ttl else cache.set(key, value)
296+
297+
try:
298+
run_async_in_sync(cache_coro)
299+
except Exception:
300+
pass # Cache storage failed - continue

src/beanllm/utils/cli/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,4 +512,4 @@ async def analyze_model(model_id: str):
512512

513513

514514
if __name__ == "__main__":
515-
main()
515+
main()

0 commit comments

Comments
 (0)