waldiez 0.5.9__py3-none-any.whl → 0.6.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.
Potentially problematic release.
This version of waldiez might be problematic. Click here for more details.
- waldiez/_version.py +1 -1
- waldiez/cli.py +113 -24
- waldiez/exporting/agent/exporter.py +9 -6
- waldiez/exporting/agent/extras/captain_agent_extras.py +44 -7
- waldiez/exporting/agent/extras/group_manager_agent_extas.py +6 -1
- waldiez/exporting/agent/extras/handoffs/after_work.py +1 -0
- waldiez/exporting/agent/extras/handoffs/available.py +1 -0
- waldiez/exporting/agent/extras/handoffs/condition.py +3 -1
- waldiez/exporting/agent/extras/handoffs/handoff.py +1 -0
- waldiez/exporting/agent/extras/handoffs/target.py +1 -0
- waldiez/exporting/agent/termination.py +1 -0
- waldiez/exporting/chats/utils/common.py +25 -23
- waldiez/exporting/core/__init__.py +0 -2
- waldiez/exporting/core/constants.py +3 -1
- waldiez/exporting/core/context.py +13 -13
- waldiez/exporting/core/extras/serializer.py +12 -10
- waldiez/exporting/core/protocols.py +0 -141
- waldiez/exporting/core/result.py +5 -5
- waldiez/exporting/core/types.py +1 -0
- waldiez/exporting/core/utils/llm_config.py +2 -2
- waldiez/exporting/flow/execution_generator.py +1 -0
- waldiez/exporting/flow/merger.py +2 -2
- waldiez/exporting/flow/orchestrator.py +1 -0
- waldiez/exporting/flow/utils/common.py +3 -3
- waldiez/exporting/flow/utils/importing.py +1 -0
- waldiez/exporting/flow/utils/logging.py +7 -80
- waldiez/exporting/tools/exporter.py +5 -0
- waldiez/exporting/tools/factory.py +4 -0
- waldiez/exporting/tools/processor.py +5 -1
- waldiez/io/__init__.py +3 -1
- waldiez/io/_ws.py +15 -5
- waldiez/io/models/content/image.py +1 -0
- waldiez/io/models/user_input.py +4 -4
- waldiez/io/models/user_response.py +1 -0
- waldiez/io/mqtt.py +1 -1
- waldiez/io/structured.py +98 -45
- waldiez/io/utils.py +17 -11
- waldiez/io/ws.py +10 -12
- waldiez/logger.py +180 -63
- waldiez/models/agents/agent/agent.py +2 -1
- waldiez/models/agents/agent/update_system_message.py +0 -2
- waldiez/models/agents/doc_agent/doc_agent.py +8 -1
- waldiez/models/chat/chat.py +1 -0
- waldiez/models/chat/chat_data.py +0 -2
- waldiez/models/common/base.py +2 -0
- waldiez/models/common/dict_utils.py +169 -40
- waldiez/models/common/handoff.py +2 -0
- waldiez/models/common/method_utils.py +2 -0
- waldiez/models/flow/flow.py +6 -6
- waldiez/models/flow/info.py +5 -1
- waldiez/models/model/_llm.py +31 -14
- waldiez/models/model/model.py +4 -1
- waldiez/models/model/model_data.py +18 -5
- waldiez/models/tool/predefined/_config.py +5 -1
- waldiez/models/tool/predefined/_duckduckgo.py +4 -0
- waldiez/models/tool/predefined/_email.py +477 -0
- waldiez/models/tool/predefined/_google.py +4 -1
- waldiez/models/tool/predefined/_perplexity.py +4 -1
- waldiez/models/tool/predefined/_searxng.py +4 -1
- waldiez/models/tool/predefined/_tavily.py +4 -1
- waldiez/models/tool/predefined/_wikipedia.py +5 -2
- waldiez/models/tool/predefined/_youtube.py +4 -1
- waldiez/models/tool/predefined/protocol.py +3 -0
- waldiez/models/tool/tool.py +22 -4
- waldiez/models/waldiez.py +12 -0
- waldiez/runner.py +37 -54
- waldiez/running/__init__.py +6 -0
- waldiez/running/base_runner.py +381 -363
- waldiez/running/environment.py +1 -0
- waldiez/running/exceptions.py +9 -0
- waldiez/running/post_run.py +10 -4
- waldiez/running/pre_run.py +199 -66
- waldiez/running/protocol.py +21 -101
- waldiez/running/run_results.py +1 -1
- waldiez/running/standard_runner.py +83 -276
- waldiez/running/step_by_step/__init__.py +46 -0
- waldiez/running/step_by_step/breakpoints_mixin.py +512 -0
- waldiez/running/step_by_step/command_handler.py +151 -0
- waldiez/running/step_by_step/events_processor.py +199 -0
- waldiez/running/step_by_step/step_by_step_models.py +541 -0
- waldiez/running/step_by_step/step_by_step_runner.py +750 -0
- waldiez/running/subprocess_runner/__base__.py +279 -0
- waldiez/running/subprocess_runner/__init__.py +16 -0
- waldiez/running/subprocess_runner/_async_runner.py +362 -0
- waldiez/running/subprocess_runner/_sync_runner.py +456 -0
- waldiez/running/subprocess_runner/runner.py +570 -0
- waldiez/running/timeline_processor.py +1 -1
- waldiez/running/utils.py +492 -3
- waldiez/utils/version.py +2 -6
- waldiez/ws/__init__.py +71 -0
- waldiez/ws/__main__.py +15 -0
- waldiez/ws/_file_handler.py +199 -0
- waldiez/ws/_mock.py +74 -0
- waldiez/ws/cli.py +235 -0
- waldiez/ws/client_manager.py +851 -0
- waldiez/ws/errors.py +416 -0
- waldiez/ws/models.py +988 -0
- waldiez/ws/reloader.py +363 -0
- waldiez/ws/server.py +508 -0
- waldiez/ws/session_manager.py +393 -0
- waldiez/ws/session_stats.py +83 -0
- waldiez/ws/utils.py +410 -0
- {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/METADATA +105 -96
- {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/RECORD +108 -83
- waldiez/running/patch_io_stream.py +0 -210
- {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/WHEEL +0 -0
- {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/entry_points.txt +0 -0
- {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {waldiez-0.5.9.dist-info → waldiez-0.6.0.dist-info}/licenses/NOTICE.md +0 -0
waldiez/running/utils.py
CHANGED
|
@@ -3,17 +3,44 @@
|
|
|
3
3
|
"""Common utilities for the waldiez runner."""
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import inspect
|
|
8
|
+
import logging
|
|
6
9
|
import os
|
|
7
10
|
import re
|
|
8
11
|
import subprocess
|
|
9
12
|
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import traceback
|
|
10
15
|
|
|
11
16
|
# noinspection PyProtectedMember
|
|
12
17
|
from asyncio.subprocess import Process
|
|
13
18
|
from contextlib import asynccontextmanager, contextmanager
|
|
14
19
|
from dataclasses import dataclass
|
|
20
|
+
from getpass import getpass
|
|
15
21
|
from pathlib import Path
|
|
16
|
-
from typing import
|
|
22
|
+
from typing import (
|
|
23
|
+
Any,
|
|
24
|
+
AsyncIterator,
|
|
25
|
+
Callable,
|
|
26
|
+
Coroutine,
|
|
27
|
+
Generic,
|
|
28
|
+
Iterator,
|
|
29
|
+
TypeVar,
|
|
30
|
+
Union,
|
|
31
|
+
cast,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
T = TypeVar("T")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _ResultContainer(Generic[T]):
|
|
40
|
+
"""Container for thread execution results with proper typing."""
|
|
41
|
+
|
|
42
|
+
result: T | None = None
|
|
43
|
+
exception: BaseException | None = None
|
|
17
44
|
|
|
18
45
|
|
|
19
46
|
@dataclass
|
|
@@ -70,6 +97,71 @@ async def a_chdir(to: Union[str, Path]) -> AsyncIterator[None]:
|
|
|
70
97
|
os.chdir(old_cwd)
|
|
71
98
|
|
|
72
99
|
|
|
100
|
+
def get_python_executable() -> str:
|
|
101
|
+
"""Get the appropriate Python executable path.
|
|
102
|
+
|
|
103
|
+
For bundled applications, this might be different from sys.executable.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
str
|
|
108
|
+
Path to the Python executable to use for pip operations.
|
|
109
|
+
"""
|
|
110
|
+
# Check if we're in a bundled application (e.g., PyInstaller)
|
|
111
|
+
if getattr(sys, "frozen", False): # pragma: no cover
|
|
112
|
+
# We're in a bundled app
|
|
113
|
+
if hasattr(sys, "_MEIPASS"):
|
|
114
|
+
sys_meipass = getattr(
|
|
115
|
+
sys, "_MEIPASS", str(Path.home() / ".waldiez" / "bin")
|
|
116
|
+
)
|
|
117
|
+
bundled = Path(sys_meipass) / "python"
|
|
118
|
+
if bundled.exists():
|
|
119
|
+
return str(bundled)
|
|
120
|
+
return sys.executable
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# noinspection TryExceptPass,PyBroadException
|
|
124
|
+
def ensure_pip() -> None: # pragma: no cover
|
|
125
|
+
"""Make sure `python -m pip` works (bootstrap via ensurepip if needed)."""
|
|
126
|
+
# pylint: disable=import-outside-toplevel
|
|
127
|
+
# pylint: disable=unused-import,broad-exception-caught
|
|
128
|
+
try:
|
|
129
|
+
import pip # noqa: F401 # pyright: ignore
|
|
130
|
+
|
|
131
|
+
return
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
try:
|
|
135
|
+
import ensurepip
|
|
136
|
+
|
|
137
|
+
ensurepip.bootstrap(upgrade=True)
|
|
138
|
+
except Exception:
|
|
139
|
+
# If bootstrap fails, we'll still attempt `-m pip` and surface errors.
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_pip_install_location() -> str | None:
|
|
144
|
+
"""Determine the best location to install packages.
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
Optional[str]
|
|
149
|
+
The installation target directory, or None for default.
|
|
150
|
+
"""
|
|
151
|
+
if getattr(sys, "frozen", False): # pragma: no cover
|
|
152
|
+
# For bundled apps, try to install to a user-writable location
|
|
153
|
+
if hasattr(sys, "_MEIPASS"):
|
|
154
|
+
app_data = Path.home() / ".waldiez" / "site-packages"
|
|
155
|
+
app_data.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
# Add to sys.path if not already there
|
|
157
|
+
app_data_str = str(app_data)
|
|
158
|
+
if app_data_str not in sys.path:
|
|
159
|
+
# after stdlib
|
|
160
|
+
sys.path.insert(1, app_data_str)
|
|
161
|
+
return app_data_str
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
73
165
|
def strip_ansi(text: str) -> str:
|
|
74
166
|
"""Remove ANSI escape sequences from text.
|
|
75
167
|
|
|
@@ -101,7 +193,7 @@ def create_sync_subprocess(setup: ProcessSetup) -> subprocess.Popen[bytes]:
|
|
|
101
193
|
The created subprocess.
|
|
102
194
|
"""
|
|
103
195
|
return subprocess.Popen(
|
|
104
|
-
[
|
|
196
|
+
[get_python_executable(), "-u", str(setup.file_path)],
|
|
105
197
|
stdout=subprocess.PIPE,
|
|
106
198
|
stderr=subprocess.PIPE,
|
|
107
199
|
stdin=subprocess.PIPE,
|
|
@@ -126,7 +218,7 @@ async def create_async_subprocess(setup: ProcessSetup) -> Process:
|
|
|
126
218
|
The created asynchronous subprocess.
|
|
127
219
|
"""
|
|
128
220
|
return await asyncio.create_subprocess_exec(
|
|
129
|
-
|
|
221
|
+
get_python_executable(),
|
|
130
222
|
"-u",
|
|
131
223
|
str(setup.file_path),
|
|
132
224
|
# stdout=asyncio.subprocess.PIPE,
|
|
@@ -134,3 +226,400 @@ async def create_async_subprocess(setup: ProcessSetup) -> Process:
|
|
|
134
226
|
# stdin=asyncio.subprocess.PIPE,
|
|
135
227
|
env={**os.environ},
|
|
136
228
|
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def input_async(prompt: str, *, password: bool = False) -> str:
|
|
232
|
+
"""Asynchronous input function.
|
|
233
|
+
|
|
234
|
+
Parameters
|
|
235
|
+
----------
|
|
236
|
+
prompt : str
|
|
237
|
+
The prompt to display to the user.
|
|
238
|
+
password : bool, optional
|
|
239
|
+
Whether to hide input (password mode), by default False.
|
|
240
|
+
|
|
241
|
+
Returns
|
|
242
|
+
-------
|
|
243
|
+
str
|
|
244
|
+
The user input.
|
|
245
|
+
"""
|
|
246
|
+
if password:
|
|
247
|
+
try:
|
|
248
|
+
return await asyncio.to_thread(getpass, prompt)
|
|
249
|
+
except EOFError:
|
|
250
|
+
return ""
|
|
251
|
+
try:
|
|
252
|
+
return await asyncio.to_thread(input, prompt)
|
|
253
|
+
except EOFError:
|
|
254
|
+
return ""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def input_sync(prompt: str, *, password: bool = False) -> str:
|
|
258
|
+
"""Input function (synchronous).
|
|
259
|
+
|
|
260
|
+
Parameters
|
|
261
|
+
----------
|
|
262
|
+
prompt : str
|
|
263
|
+
The prompt to display to the user.
|
|
264
|
+
password : bool, optional
|
|
265
|
+
Whether to hide input (password mode), by default False.
|
|
266
|
+
|
|
267
|
+
Returns
|
|
268
|
+
-------
|
|
269
|
+
str
|
|
270
|
+
The user input.
|
|
271
|
+
"""
|
|
272
|
+
if password:
|
|
273
|
+
try:
|
|
274
|
+
return getpass(prompt)
|
|
275
|
+
except EOFError:
|
|
276
|
+
return ""
|
|
277
|
+
try:
|
|
278
|
+
return input(prompt)
|
|
279
|
+
except EOFError:
|
|
280
|
+
return ""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# pylint: disable=import-outside-toplevel,too-complex
|
|
284
|
+
def get_printer() -> Callable[..., None]: # noqa: C901
|
|
285
|
+
"""Get the printer function.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
Callable[..., None]
|
|
290
|
+
The printer function that handles Unicode encoding errors gracefully.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
# noinspection PyUnresolvedReferences
|
|
294
|
+
from autogen.io import IOStream # type: ignore
|
|
295
|
+
|
|
296
|
+
printer = IOStream.get_default().print
|
|
297
|
+
except ImportError: # pragma: no cover
|
|
298
|
+
# Fallback to standard print if autogen is not available
|
|
299
|
+
printer = print
|
|
300
|
+
|
|
301
|
+
# noinspection PyBroadException,TryExceptPass
|
|
302
|
+
def safe_printer(*args: Any, **kwargs: Any) -> None: # noqa: C901
|
|
303
|
+
"""Safe printer that handles Unicode encoding errors.
|
|
304
|
+
|
|
305
|
+
Parameters
|
|
306
|
+
----------
|
|
307
|
+
*args : Any
|
|
308
|
+
Arguments to pass to the printer
|
|
309
|
+
**kwargs : Any
|
|
310
|
+
Keyword arguments to pass to the printer
|
|
311
|
+
"""
|
|
312
|
+
# pylint: disable=broad-exception-caught,too-many-try-statements
|
|
313
|
+
try:
|
|
314
|
+
printer(*args, **kwargs)
|
|
315
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
316
|
+
# First fallback: try to get a safe string representation
|
|
317
|
+
try:
|
|
318
|
+
msg, flush = get_what_to_print(*args, **kwargs)
|
|
319
|
+
# Convert problematic characters to safe representations
|
|
320
|
+
safe_msg = msg.encode("utf-8", errors="replace").decode("utf-8")
|
|
321
|
+
printer(safe_msg, end="", flush=flush)
|
|
322
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
323
|
+
# Second fallback: use built-in print with safe encoding
|
|
324
|
+
try:
|
|
325
|
+
# Convert args to safe string representations
|
|
326
|
+
safe_args: list[str] = []
|
|
327
|
+
for arg in args:
|
|
328
|
+
try:
|
|
329
|
+
safe_args.append(
|
|
330
|
+
str(arg)
|
|
331
|
+
.encode("utf-8", errors="replace")
|
|
332
|
+
.decode("utf-8")
|
|
333
|
+
)
|
|
334
|
+
except (
|
|
335
|
+
UnicodeEncodeError,
|
|
336
|
+
UnicodeDecodeError,
|
|
337
|
+
): # pragma: no cover
|
|
338
|
+
safe_args.append(repr(arg))
|
|
339
|
+
|
|
340
|
+
# Use built-in print instead of the custom printer
|
|
341
|
+
print(*safe_args, **kwargs)
|
|
342
|
+
|
|
343
|
+
except Exception:
|
|
344
|
+
# Final fallback: write directly to stderr buffer
|
|
345
|
+
error_msg = (
|
|
346
|
+
"Could not print the message due to encoding issues.\n"
|
|
347
|
+
)
|
|
348
|
+
to_sys_stderr(error_msg)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
# Handle any other unexpected errors
|
|
351
|
+
traceback.print_exc()
|
|
352
|
+
error_msg = f"Unexpected error in printer: {str(e)}\n"
|
|
353
|
+
to_sys_stderr(error_msg)
|
|
354
|
+
|
|
355
|
+
return safe_printer
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def to_sys_stderr(msg: str) -> None:
|
|
359
|
+
"""Write a message to sys.stderr.
|
|
360
|
+
|
|
361
|
+
Parameters
|
|
362
|
+
----------
|
|
363
|
+
msg : str
|
|
364
|
+
The message to write to stderr.
|
|
365
|
+
"""
|
|
366
|
+
# pylint: disable=broad-exception-caught
|
|
367
|
+
# noinspection TryExceptPass,PyBroadException
|
|
368
|
+
try:
|
|
369
|
+
if hasattr(sys.stderr, "buffer"):
|
|
370
|
+
sys.stderr.buffer.write(msg.encode("utf-8", errors="replace"))
|
|
371
|
+
sys.stderr.buffer.flush()
|
|
372
|
+
else: # pragma: no cover
|
|
373
|
+
sys.stderr.write(msg)
|
|
374
|
+
sys.stderr.flush()
|
|
375
|
+
except Exception: # pragma: no cover
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def get_what_to_print(*args: Any, **kwargs: Any) -> tuple[str, bool]:
|
|
380
|
+
"""Extract message and flush flag from print arguments.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
*args : Any
|
|
385
|
+
Arguments to print
|
|
386
|
+
**kwargs : Any
|
|
387
|
+
Keyword arguments for print function
|
|
388
|
+
|
|
389
|
+
Returns
|
|
390
|
+
-------
|
|
391
|
+
tuple[str, bool]
|
|
392
|
+
Message to print and flush flag
|
|
393
|
+
"""
|
|
394
|
+
# Convert all args to strings and join with spaces (like print does)
|
|
395
|
+
msg = " ".join(str(arg) for arg in args)
|
|
396
|
+
|
|
397
|
+
# Handle sep parameter
|
|
398
|
+
sep = kwargs.get("sep", " ")
|
|
399
|
+
if isinstance(sep, bytes): # pragma: no cover
|
|
400
|
+
sep = sep.decode("utf-8", errors="replace")
|
|
401
|
+
if len(args) > 1:
|
|
402
|
+
msg = sep.join(str(arg) for arg in args)
|
|
403
|
+
|
|
404
|
+
# Handle end parameter
|
|
405
|
+
end = kwargs.get("end", "\n")
|
|
406
|
+
if isinstance(end, bytes): # pragma: no cover
|
|
407
|
+
end = end.decode("utf-8", errors="replace")
|
|
408
|
+
msg += end
|
|
409
|
+
|
|
410
|
+
# Handle flush parameter
|
|
411
|
+
flush = kwargs.get("flush", False)
|
|
412
|
+
# noinspection PyUnreachableCode
|
|
413
|
+
if not isinstance(flush, bool): # pragma: no cover
|
|
414
|
+
flush = False
|
|
415
|
+
|
|
416
|
+
return msg, flush
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def is_async_callable(fn: Any) -> bool:
|
|
420
|
+
"""Check if a function is async callable, including partials/callables.
|
|
421
|
+
|
|
422
|
+
Parameters
|
|
423
|
+
----------
|
|
424
|
+
fn : Any
|
|
425
|
+
The function to check.
|
|
426
|
+
|
|
427
|
+
Returns
|
|
428
|
+
-------
|
|
429
|
+
bool
|
|
430
|
+
True if the function is async callable, False otherwise.
|
|
431
|
+
"""
|
|
432
|
+
if isinstance(fn, functools.partial):
|
|
433
|
+
fn = fn.func
|
|
434
|
+
unwrapped = inspect.unwrap(fn)
|
|
435
|
+
return inspect.iscoroutinefunction(
|
|
436
|
+
unwrapped
|
|
437
|
+
) or inspect.iscoroutinefunction(
|
|
438
|
+
getattr(unwrapped, "__call__", None), # noqa: B004
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def syncify(
|
|
443
|
+
async_func: Callable[..., Coroutine[Any, Any, T]],
|
|
444
|
+
timeout: float | None = None,
|
|
445
|
+
) -> Callable[..., T]:
|
|
446
|
+
"""Convert an async function to a sync function.
|
|
447
|
+
|
|
448
|
+
This function handles the conversion of async functions to sync functions,
|
|
449
|
+
properly managing event loops and thread execution contexts.
|
|
450
|
+
|
|
451
|
+
Parameters
|
|
452
|
+
----------
|
|
453
|
+
async_func : Callable[..., Coroutine[Any, Any, T]]
|
|
454
|
+
The async function to convert.
|
|
455
|
+
timeout : float | None, optional
|
|
456
|
+
The timeout for the sync function. Defaults to None.
|
|
457
|
+
|
|
458
|
+
Returns
|
|
459
|
+
-------
|
|
460
|
+
Callable[..., T]
|
|
461
|
+
The converted sync function.
|
|
462
|
+
|
|
463
|
+
Raises
|
|
464
|
+
------
|
|
465
|
+
TimeoutError
|
|
466
|
+
If the async function times out.
|
|
467
|
+
RuntimeError
|
|
468
|
+
If there are issues with event loop management.
|
|
469
|
+
"""
|
|
470
|
+
|
|
471
|
+
def _sync_wrapper(*args: Any, **kwargs: Any) -> T:
|
|
472
|
+
"""Get the result of the async function."""
|
|
473
|
+
# pylint: disable=too-many-try-statements
|
|
474
|
+
try:
|
|
475
|
+
# Check if we're already in an event loop
|
|
476
|
+
asyncio.get_running_loop()
|
|
477
|
+
return _run_in_thread(async_func, args, kwargs, timeout)
|
|
478
|
+
except RuntimeError:
|
|
479
|
+
# No event loop running, we can use asyncio.run directly
|
|
480
|
+
logger.debug("No event loop running, using asyncio.run")
|
|
481
|
+
|
|
482
|
+
# Create a new event loop and run the coroutine
|
|
483
|
+
try:
|
|
484
|
+
if timeout is not None:
|
|
485
|
+
# Need to run with asyncio.run and wait_for inside
|
|
486
|
+
async def _with_timeout() -> T:
|
|
487
|
+
return await asyncio.wait_for(
|
|
488
|
+
async_func(*args, **kwargs), timeout=timeout
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
return asyncio.run(_with_timeout())
|
|
492
|
+
return asyncio.run(async_func(*args, **kwargs))
|
|
493
|
+
except (asyncio.TimeoutError, TimeoutError) as e:
|
|
494
|
+
raise TimeoutError(
|
|
495
|
+
f"Async function timed out after {timeout} seconds"
|
|
496
|
+
) from e
|
|
497
|
+
|
|
498
|
+
return _sync_wrapper
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _run_in_thread(
|
|
502
|
+
async_func: Callable[..., Coroutine[Any, Any, T]],
|
|
503
|
+
args: tuple[Any, ...],
|
|
504
|
+
kwargs: dict[str, Any],
|
|
505
|
+
timeout: float | None,
|
|
506
|
+
) -> T:
|
|
507
|
+
"""Run async function in a separate thread.
|
|
508
|
+
|
|
509
|
+
Parameters
|
|
510
|
+
----------
|
|
511
|
+
async_func : Callable[..., Coroutine[Any, Any, T]]
|
|
512
|
+
The async function to run.
|
|
513
|
+
args : tuple[Any, ...]
|
|
514
|
+
Positional arguments for the function.
|
|
515
|
+
kwargs : dict[str, Any]
|
|
516
|
+
Keyword arguments for the function.
|
|
517
|
+
timeout : float | None
|
|
518
|
+
Timeout in seconds.
|
|
519
|
+
|
|
520
|
+
Returns
|
|
521
|
+
-------
|
|
522
|
+
T
|
|
523
|
+
The result of the async function.
|
|
524
|
+
|
|
525
|
+
Raises
|
|
526
|
+
------
|
|
527
|
+
TimeoutError
|
|
528
|
+
If the function execution times out.
|
|
529
|
+
RuntimeError
|
|
530
|
+
If thread execution fails unexpectedly.
|
|
531
|
+
"""
|
|
532
|
+
result_container: _ResultContainer[T] = _ResultContainer()
|
|
533
|
+
finished_event = threading.Event()
|
|
534
|
+
|
|
535
|
+
def _thread_target() -> None:
|
|
536
|
+
"""Target function for the thread."""
|
|
537
|
+
# pylint: disable=too-many-try-statements, broad-exception-caught
|
|
538
|
+
try:
|
|
539
|
+
if timeout is not None:
|
|
540
|
+
|
|
541
|
+
async def _with_timeout() -> T:
|
|
542
|
+
return await asyncio.wait_for(
|
|
543
|
+
async_func(*args, **kwargs), timeout=timeout
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
result_container.result = asyncio.run(_with_timeout())
|
|
547
|
+
else:
|
|
548
|
+
result_container.result = asyncio.run(
|
|
549
|
+
async_func(*args, **kwargs)
|
|
550
|
+
)
|
|
551
|
+
except (
|
|
552
|
+
BaseException
|
|
553
|
+
) as e: # Catch BaseException to propagate cancellations
|
|
554
|
+
result_container.exception = e
|
|
555
|
+
finally:
|
|
556
|
+
finished_event.set()
|
|
557
|
+
|
|
558
|
+
thread = threading.Thread(target=_thread_target, daemon=True)
|
|
559
|
+
thread.start()
|
|
560
|
+
|
|
561
|
+
# Wait for completion with timeout
|
|
562
|
+
timeout_buffer = 1.0 # 1 second buffer for cleanup
|
|
563
|
+
wait_timeout = timeout + timeout_buffer if timeout is not None else None
|
|
564
|
+
|
|
565
|
+
if not finished_event.wait(timeout=wait_timeout): # pragma: no cover
|
|
566
|
+
raise TimeoutError(
|
|
567
|
+
f"Function execution timed out after {timeout} seconds"
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
thread.join(timeout=timeout_buffer) # Give thread time to clean up
|
|
571
|
+
|
|
572
|
+
if result_container.exception is not None:
|
|
573
|
+
raise result_container.exception
|
|
574
|
+
|
|
575
|
+
# Use cast since we know the result should be T if no exception occurred
|
|
576
|
+
return cast(T, result_container.result)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def safe_filename(name: str, ext: str = "") -> str:
|
|
580
|
+
"""
|
|
581
|
+
Make a safe cross-platform filename from an arbitrary string.
|
|
582
|
+
|
|
583
|
+
Parameters
|
|
584
|
+
----------
|
|
585
|
+
name : str
|
|
586
|
+
The string to turn into a safe filename.
|
|
587
|
+
ext : str
|
|
588
|
+
Optional extension (with or without leading dot).
|
|
589
|
+
|
|
590
|
+
Returns
|
|
591
|
+
-------
|
|
592
|
+
str
|
|
593
|
+
A safe filename string.
|
|
594
|
+
"""
|
|
595
|
+
# Normalize extension
|
|
596
|
+
# pylint: disable=inconsistent-quotes
|
|
597
|
+
ext = f".{ext.lstrip('.')}" if ext else ""
|
|
598
|
+
|
|
599
|
+
# Forbidden characters on Windows (also bad on Unix)
|
|
600
|
+
forbidden = r'[<>:"/\\|?*\x00-\x1F]'
|
|
601
|
+
name = re.sub(forbidden, "_", name)
|
|
602
|
+
|
|
603
|
+
# Trim trailing dots/spaces (illegal on Windows)
|
|
604
|
+
name = name.rstrip(". ")
|
|
605
|
+
|
|
606
|
+
# Collapse multiple underscores
|
|
607
|
+
name = re.sub(r"_+", "_", name)
|
|
608
|
+
|
|
609
|
+
# Reserved Windows device names
|
|
610
|
+
reserved = re.compile(
|
|
611
|
+
r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])$", re.IGNORECASE
|
|
612
|
+
)
|
|
613
|
+
if reserved.match(name):
|
|
614
|
+
name = f"_{name}"
|
|
615
|
+
|
|
616
|
+
# Fallback if empty
|
|
617
|
+
if not name:
|
|
618
|
+
name = "file"
|
|
619
|
+
|
|
620
|
+
# Ensure length limit (NTFS max filename length = 255 bytes)
|
|
621
|
+
max_len = 255 - len(ext)
|
|
622
|
+
if len(name) > max_len:
|
|
623
|
+
name = name[:max_len]
|
|
624
|
+
|
|
625
|
+
return f"{name}{ext}"
|
waldiez/utils/version.py
CHANGED
|
@@ -30,7 +30,7 @@ def _get_waldiez_version_from_package_json() -> str | None:
|
|
|
30
30
|
def _get_waldiez_version_from_version_py() -> str | None:
|
|
31
31
|
"""Get the Waldiez version from _version.py."""
|
|
32
32
|
version_py_path = Path(__file__).parent.parent / "_version.py"
|
|
33
|
-
if version_py_path.exists():
|
|
33
|
+
if version_py_path.exists(): # pragma: no branch
|
|
34
34
|
with open(version_py_path, "r", encoding="utf-8") as f:
|
|
35
35
|
for line in f:
|
|
36
36
|
if line.startswith("__version__"):
|
|
@@ -48,14 +48,10 @@ def get_waldiez_version() -> str:
|
|
|
48
48
|
The Waldiez version, or "dev" if not found.
|
|
49
49
|
"""
|
|
50
50
|
w_version = _get_waldiez_version_from_importlib()
|
|
51
|
-
if not w_version:
|
|
51
|
+
if not w_version: # pragma: no branch
|
|
52
52
|
w_version = _get_waldiez_version_from_version_py()
|
|
53
53
|
if not w_version:
|
|
54
54
|
w_version = _get_waldiez_version_from_package_json()
|
|
55
55
|
if not w_version:
|
|
56
56
|
w_version = "dev"
|
|
57
57
|
return w_version
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if __name__ == "__main__":
|
|
61
|
-
print(get_waldiez_version())
|
waldiez/ws/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0.
|
|
2
|
+
# Copyright (c) 2024 - 2025 Waldiez and contributors.
|
|
3
|
+
# pylint: disable=duplicate-code
|
|
4
|
+
"""Waldiez WebSocket server module."""
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from typer.models import CommandInfo
|
|
8
|
+
|
|
9
|
+
from .cli import serve
|
|
10
|
+
from .client_manager import ClientManager
|
|
11
|
+
from .errors import (
|
|
12
|
+
ErrorCode,
|
|
13
|
+
ErrorHandler,
|
|
14
|
+
MessageHandlingError,
|
|
15
|
+
MessageParsingError,
|
|
16
|
+
OperationTimeoutError,
|
|
17
|
+
ServerOverloadError,
|
|
18
|
+
UnsupportedActionError,
|
|
19
|
+
WaldiezServerError,
|
|
20
|
+
)
|
|
21
|
+
from .server import HAS_WEBSOCKETS, WaldiezWsServer, run_server
|
|
22
|
+
from .session_manager import SessionManager
|
|
23
|
+
from .utils import (
|
|
24
|
+
ConnectionManager,
|
|
25
|
+
HealthChecker,
|
|
26
|
+
ServerHealth,
|
|
27
|
+
get_available_port,
|
|
28
|
+
is_port_available,
|
|
29
|
+
test_server_connection,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def add_ws_app(app: typer.Typer) -> None:
|
|
34
|
+
"""Add WebSocket server commands to the CLI.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
app : typer.Typer
|
|
39
|
+
The Typer application instance.
|
|
40
|
+
"""
|
|
41
|
+
if HAS_WEBSOCKETS:
|
|
42
|
+
app.registered_commands.append(
|
|
43
|
+
CommandInfo(
|
|
44
|
+
name="ws",
|
|
45
|
+
help="Start the Waldiez WebSocket server.",
|
|
46
|
+
callback=serve,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"WaldiezWsServer",
|
|
53
|
+
"run_server",
|
|
54
|
+
"ClientManager",
|
|
55
|
+
"ConnectionManager",
|
|
56
|
+
"HealthChecker",
|
|
57
|
+
"ServerHealth",
|
|
58
|
+
"test_server_connection",
|
|
59
|
+
"ErrorHandler",
|
|
60
|
+
"ErrorCode",
|
|
61
|
+
"MessageParsingError",
|
|
62
|
+
"MessageHandlingError",
|
|
63
|
+
"UnsupportedActionError",
|
|
64
|
+
"ServerOverloadError",
|
|
65
|
+
"SessionManager",
|
|
66
|
+
"OperationTimeoutError",
|
|
67
|
+
"WaldiezServerError",
|
|
68
|
+
"get_available_port",
|
|
69
|
+
"is_port_available",
|
|
70
|
+
"add_ws_app",
|
|
71
|
+
]
|
waldiez/ws/__main__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0.
|
|
2
|
+
# Copyright (c) 2024 - 2025 Waldiez and contributors.
|
|
3
|
+
"""Main entry point for Waldiez WebSocket server."""
|
|
4
|
+
|
|
5
|
+
import sys # pragma: no cover
|
|
6
|
+
from pathlib import Path # pragma: no cover
|
|
7
|
+
|
|
8
|
+
try: # pragma: no cover
|
|
9
|
+
from .cli import app
|
|
10
|
+
except ImportError: # pragma: no cover
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
12
|
+
from waldiez.ws.cli import app
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
app()
|