waldiez 0.5.8__py3-none-any.whl → 0.5.10__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.

Files changed (88) hide show
  1. waldiez/_version.py +1 -1
  2. waldiez/cli.py +112 -24
  3. waldiez/exporting/agent/exporter.py +3 -0
  4. waldiez/exporting/agent/extras/captain_agent_extras.py +44 -7
  5. waldiez/exporting/agent/extras/handoffs/condition.py +3 -1
  6. waldiez/exporting/chats/utils/common.py +25 -23
  7. waldiez/exporting/core/__init__.py +0 -2
  8. waldiez/exporting/core/context.py +13 -13
  9. waldiez/exporting/core/protocols.py +0 -141
  10. waldiez/exporting/core/result.py +5 -5
  11. waldiez/exporting/flow/merger.py +2 -2
  12. waldiez/exporting/flow/orchestrator.py +1 -0
  13. waldiez/exporting/flow/utils/common.py +2 -2
  14. waldiez/exporting/flow/utils/importing.py +1 -0
  15. waldiez/exporting/flow/utils/logging.py +6 -7
  16. waldiez/exporting/tools/exporter.py +5 -0
  17. waldiez/exporting/tools/factory.py +4 -0
  18. waldiez/exporting/tools/processor.py +5 -1
  19. waldiez/io/_ws.py +13 -5
  20. waldiez/io/models/content/image.py +1 -0
  21. waldiez/io/models/user_input.py +4 -4
  22. waldiez/io/models/user_response.py +1 -0
  23. waldiez/io/mqtt.py +1 -1
  24. waldiez/io/structured.py +17 -17
  25. waldiez/io/utils.py +1 -1
  26. waldiez/io/ws.py +9 -11
  27. waldiez/logger.py +180 -63
  28. waldiez/models/agents/agent/update_system_message.py +0 -2
  29. waldiez/models/agents/doc_agent/doc_agent.py +8 -1
  30. waldiez/models/common/dict_utils.py +169 -40
  31. waldiez/models/flow/flow.py +6 -6
  32. waldiez/models/flow/info.py +5 -1
  33. waldiez/models/model/_llm.py +28 -14
  34. waldiez/models/model/model.py +4 -1
  35. waldiez/models/model/model_data.py +18 -5
  36. waldiez/models/tool/predefined/_config.py +5 -1
  37. waldiez/models/tool/predefined/_duckduckgo.py +4 -0
  38. waldiez/models/tool/predefined/_email.py +474 -0
  39. waldiez/models/tool/predefined/_google.py +8 -6
  40. waldiez/models/tool/predefined/_perplexity.py +3 -0
  41. waldiez/models/tool/predefined/_searxng.py +3 -0
  42. waldiez/models/tool/predefined/_tavily.py +4 -1
  43. waldiez/models/tool/predefined/_wikipedia.py +4 -1
  44. waldiez/models/tool/predefined/_youtube.py +4 -1
  45. waldiez/models/tool/predefined/protocol.py +3 -0
  46. waldiez/models/tool/tool.py +22 -4
  47. waldiez/models/waldiez.py +12 -0
  48. waldiez/runner.py +37 -54
  49. waldiez/running/__init__.py +6 -0
  50. waldiez/running/base_runner.py +310 -353
  51. waldiez/running/environment.py +1 -0
  52. waldiez/running/exceptions.py +9 -0
  53. waldiez/running/post_run.py +4 -4
  54. waldiez/running/pre_run.py +51 -40
  55. waldiez/running/protocol.py +21 -101
  56. waldiez/running/run_results.py +1 -1
  57. waldiez/running/standard_runner.py +84 -277
  58. waldiez/running/step_by_step/__init__.py +46 -0
  59. waldiez/running/step_by_step/breakpoints_mixin.py +188 -0
  60. waldiez/running/step_by_step/step_by_step_models.py +224 -0
  61. waldiez/running/step_by_step/step_by_step_runner.py +745 -0
  62. waldiez/running/subprocess_runner/__base__.py +282 -0
  63. waldiez/running/subprocess_runner/__init__.py +16 -0
  64. waldiez/running/subprocess_runner/_async_runner.py +362 -0
  65. waldiez/running/subprocess_runner/_sync_runner.py +455 -0
  66. waldiez/running/subprocess_runner/runner.py +561 -0
  67. waldiez/running/timeline_processor.py +1 -1
  68. waldiez/running/utils.py +376 -1
  69. waldiez/utils/version.py +2 -6
  70. waldiez/ws/__init__.py +70 -0
  71. waldiez/ws/__main__.py +15 -0
  72. waldiez/ws/_file_handler.py +201 -0
  73. waldiez/ws/cli.py +211 -0
  74. waldiez/ws/client_manager.py +835 -0
  75. waldiez/ws/errors.py +416 -0
  76. waldiez/ws/models.py +971 -0
  77. waldiez/ws/reloader.py +342 -0
  78. waldiez/ws/server.py +469 -0
  79. waldiez/ws/session_manager.py +393 -0
  80. waldiez/ws/session_stats.py +83 -0
  81. waldiez/ws/utils.py +385 -0
  82. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/METADATA +74 -74
  83. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/RECORD +87 -65
  84. waldiez/running/patch_io_stream.py +0 -210
  85. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/WHEEL +0 -0
  86. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/entry_points.txt +0 -0
  87. {waldiez-0.5.8.dist-info → waldiez-0.5.10.dist-info}/licenses/LICENSE +0 -0
  88. {waldiez-0.5.8.dist-info → waldiez-0.5.10.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 AsyncIterator, Iterator, Union
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
@@ -134,3 +161,351 @@ async def create_async_subprocess(setup: ProcessSetup) -> Process:
134
161
  # stdin=asyncio.subprocess.PIPE,
135
162
  env={**os.environ},
136
163
  )
164
+
165
+
166
+ async def input_async(prompt: str, *, password: bool = False) -> str:
167
+ """Asynchronous input function.
168
+
169
+ Parameters
170
+ ----------
171
+ prompt : str
172
+ The prompt to display to the user.
173
+ password : bool, optional
174
+ Whether to hide input (password mode), by default False.
175
+
176
+ Returns
177
+ -------
178
+ str
179
+ The user input.
180
+ """
181
+ if password:
182
+ try:
183
+ return await asyncio.to_thread(getpass, prompt)
184
+ except EOFError:
185
+ return ""
186
+ try:
187
+ return await asyncio.to_thread(input, prompt)
188
+ except EOFError:
189
+ return ""
190
+
191
+
192
+ def input_sync(prompt: str, *, password: bool = False) -> str:
193
+ """Input function (synchronous).
194
+
195
+ Parameters
196
+ ----------
197
+ prompt : str
198
+ The prompt to display to the user.
199
+ password : bool, optional
200
+ Whether to hide input (password mode), by default False.
201
+
202
+ Returns
203
+ -------
204
+ str
205
+ The user input.
206
+ """
207
+ if password:
208
+ try:
209
+ return getpass(prompt)
210
+ except EOFError:
211
+ return ""
212
+ try:
213
+ return input(prompt)
214
+ except EOFError:
215
+ return ""
216
+
217
+
218
+ # pylint: disable=import-outside-toplevel,too-complex
219
+ def get_printer() -> Callable[..., None]: # noqa: C901
220
+ """Get the printer function.
221
+
222
+ Returns
223
+ -------
224
+ Callable[..., None]
225
+ The printer function that handles Unicode encoding errors gracefully.
226
+ """
227
+ try:
228
+ # noinspection PyUnresolvedReferences
229
+ from autogen.io import IOStream # type: ignore
230
+
231
+ printer = IOStream.get_default().print
232
+ except ImportError: # pragma: no cover
233
+ # Fallback to standard print if autogen is not available
234
+ printer = print
235
+
236
+ # noinspection PyBroadException,TryExceptPass
237
+ def safe_printer(*args: Any, **kwargs: Any) -> None: # noqa: C901
238
+ """Safe printer that handles Unicode encoding errors.
239
+
240
+ Parameters
241
+ ----------
242
+ *args : Any
243
+ Arguments to pass to the printer
244
+ **kwargs : Any
245
+ Keyword arguments to pass to the printer
246
+ """
247
+ # pylint: disable=broad-exception-caught,too-many-try-statements
248
+ try:
249
+ printer(*args, **kwargs)
250
+ except (UnicodeEncodeError, UnicodeDecodeError):
251
+ # First fallback: try to get a safe string representation
252
+ try:
253
+ msg, flush = get_what_to_print(*args, **kwargs)
254
+ # Convert problematic characters to safe representations
255
+ safe_msg = msg.encode("utf-8", errors="replace").decode("utf-8")
256
+ printer(safe_msg, end="", flush=flush)
257
+ except (UnicodeEncodeError, UnicodeDecodeError):
258
+ # Second fallback: use built-in print with safe encoding
259
+ try:
260
+ # Convert args to safe string representations
261
+ safe_args: list[str] = []
262
+ for arg in args:
263
+ try:
264
+ safe_args.append(
265
+ str(arg)
266
+ .encode("utf-8", errors="replace")
267
+ .decode("utf-8")
268
+ )
269
+ except (
270
+ UnicodeEncodeError,
271
+ UnicodeDecodeError,
272
+ ): # pragma: no cover
273
+ safe_args.append(repr(arg))
274
+
275
+ # Use built-in print instead of the custom printer
276
+ print(*safe_args, **kwargs)
277
+
278
+ except Exception:
279
+ # Final fallback: write directly to stderr buffer
280
+ error_msg = (
281
+ "Could not print the message due to encoding issues.\n"
282
+ )
283
+ to_sys_stderr(error_msg)
284
+ except Exception as e:
285
+ # Handle any other unexpected errors
286
+ traceback.print_exc()
287
+ error_msg = f"Unexpected error in printer: {str(e)}\n"
288
+ to_sys_stderr(error_msg)
289
+
290
+ return safe_printer
291
+
292
+
293
+ def to_sys_stderr(msg: str) -> None:
294
+ """Write a message to sys.stderr.
295
+
296
+ Parameters
297
+ ----------
298
+ msg : str
299
+ The message to write to stderr.
300
+ """
301
+ # pylint: disable=broad-exception-caught
302
+ # noinspection TryExceptPass,PyBroadException
303
+ try:
304
+ if hasattr(sys.stderr, "buffer"):
305
+ sys.stderr.buffer.write(msg.encode("utf-8", errors="replace"))
306
+ sys.stderr.buffer.flush()
307
+ else: # pragma: no cover
308
+ sys.stderr.write(msg)
309
+ sys.stderr.flush()
310
+ except Exception: # pragma: no cover
311
+ pass
312
+
313
+
314
+ def get_what_to_print(*args: Any, **kwargs: Any) -> tuple[str, bool]:
315
+ """Extract message and flush flag from print arguments.
316
+
317
+ Parameters
318
+ ----------
319
+ *args : Any
320
+ Arguments to print
321
+ **kwargs : Any
322
+ Keyword arguments for print function
323
+
324
+ Returns
325
+ -------
326
+ tuple[str, bool]
327
+ Message to print and flush flag
328
+ """
329
+ # Convert all args to strings and join with spaces (like print does)
330
+ msg = " ".join(str(arg) for arg in args)
331
+
332
+ # Handle sep parameter
333
+ sep = kwargs.get("sep", " ")
334
+ if isinstance(sep, bytes): # pragma: no cover
335
+ sep = sep.decode("utf-8", errors="replace")
336
+ if len(args) > 1:
337
+ msg = sep.join(str(arg) for arg in args)
338
+
339
+ # Handle end parameter
340
+ end = kwargs.get("end", "\n")
341
+ if isinstance(end, bytes): # pragma: no cover
342
+ end = end.decode("utf-8", errors="replace")
343
+ msg += end
344
+
345
+ # Handle flush parameter
346
+ flush = kwargs.get("flush", False)
347
+ # noinspection PyUnreachableCode
348
+ if not isinstance(flush, bool): # pragma: no cover
349
+ flush = False
350
+
351
+ return msg, flush
352
+
353
+
354
+ def is_async_callable(fn: Any) -> bool:
355
+ """Check if a function is async callable, including partials/callables.
356
+
357
+ Parameters
358
+ ----------
359
+ fn : Any
360
+ The function to check.
361
+
362
+ Returns
363
+ -------
364
+ bool
365
+ True if the function is async callable, False otherwise.
366
+ """
367
+ if isinstance(fn, functools.partial):
368
+ fn = fn.func
369
+ unwrapped = inspect.unwrap(fn)
370
+ return inspect.iscoroutinefunction(
371
+ unwrapped
372
+ ) or inspect.iscoroutinefunction(
373
+ getattr(unwrapped, "__call__", None), # noqa: B004
374
+ )
375
+
376
+
377
+ def syncify(
378
+ async_func: Callable[..., Coroutine[Any, Any, T]],
379
+ timeout: float | None = None,
380
+ ) -> Callable[..., T]:
381
+ """Convert an async function to a sync function.
382
+
383
+ This function handles the conversion of async functions to sync functions,
384
+ properly managing event loops and thread execution contexts.
385
+
386
+ Parameters
387
+ ----------
388
+ async_func : Callable[..., Coroutine[Any, Any, T]]
389
+ The async function to convert.
390
+ timeout : float | None, optional
391
+ The timeout for the sync function. Defaults to None.
392
+
393
+ Returns
394
+ -------
395
+ Callable[..., T]
396
+ The converted sync function.
397
+
398
+ Raises
399
+ ------
400
+ TimeoutError
401
+ If the async function times out.
402
+ RuntimeError
403
+ If there are issues with event loop management.
404
+ """
405
+
406
+ def _sync_wrapper(*args: Any, **kwargs: Any) -> T:
407
+ """Get the result of the async function."""
408
+ # pylint: disable=too-many-try-statements
409
+ try:
410
+ # Check if we're already in an event loop
411
+ asyncio.get_running_loop()
412
+ return _run_in_thread(async_func, args, kwargs, timeout)
413
+ except RuntimeError:
414
+ # No event loop running, we can use asyncio.run directly
415
+ logger.debug("No event loop running, using asyncio.run")
416
+
417
+ # Create a new event loop and run the coroutine
418
+ try:
419
+ if timeout is not None:
420
+ # Need to run with asyncio.run and wait_for inside
421
+ async def _with_timeout() -> T:
422
+ return await asyncio.wait_for(
423
+ async_func(*args, **kwargs), timeout=timeout
424
+ )
425
+
426
+ return asyncio.run(_with_timeout())
427
+ return asyncio.run(async_func(*args, **kwargs))
428
+ except (asyncio.TimeoutError, TimeoutError) as e:
429
+ raise TimeoutError(
430
+ f"Async function timed out after {timeout} seconds"
431
+ ) from e
432
+
433
+ return _sync_wrapper
434
+
435
+
436
+ def _run_in_thread(
437
+ async_func: Callable[..., Coroutine[Any, Any, T]],
438
+ args: tuple[Any, ...],
439
+ kwargs: dict[str, Any],
440
+ timeout: float | None,
441
+ ) -> T:
442
+ """Run async function in a separate thread.
443
+
444
+ Parameters
445
+ ----------
446
+ async_func : Callable[..., Coroutine[Any, Any, T]]
447
+ The async function to run.
448
+ args : tuple[Any, ...]
449
+ Positional arguments for the function.
450
+ kwargs : dict[str, Any]
451
+ Keyword arguments for the function.
452
+ timeout : float | None
453
+ Timeout in seconds.
454
+
455
+ Returns
456
+ -------
457
+ T
458
+ The result of the async function.
459
+
460
+ Raises
461
+ ------
462
+ TimeoutError
463
+ If the function execution times out.
464
+ RuntimeError
465
+ If thread execution fails unexpectedly.
466
+ """
467
+ result_container: _ResultContainer[T] = _ResultContainer()
468
+ finished_event = threading.Event()
469
+
470
+ def _thread_target() -> None:
471
+ """Target function for the thread."""
472
+ # pylint: disable=too-many-try-statements, broad-exception-caught
473
+ try:
474
+ if timeout is not None:
475
+
476
+ async def _with_timeout() -> T:
477
+ return await asyncio.wait_for(
478
+ async_func(*args, **kwargs), timeout=timeout
479
+ )
480
+
481
+ result_container.result = asyncio.run(_with_timeout())
482
+ else:
483
+ result_container.result = asyncio.run(
484
+ async_func(*args, **kwargs)
485
+ )
486
+ except (
487
+ BaseException
488
+ ) as e: # Catch BaseException to propagate cancellations
489
+ result_container.exception = e
490
+ finally:
491
+ finished_event.set()
492
+
493
+ thread = threading.Thread(target=_thread_target, daemon=True)
494
+ thread.start()
495
+
496
+ # Wait for completion with timeout
497
+ timeout_buffer = 1.0 # 1 second buffer for cleanup
498
+ wait_timeout = timeout + timeout_buffer if timeout is not None else None
499
+
500
+ if not finished_event.wait(timeout=wait_timeout): # pragma: no cover
501
+ raise TimeoutError(
502
+ f"Function execution timed out after {timeout} seconds"
503
+ )
504
+
505
+ thread.join(timeout=timeout_buffer) # Give thread time to clean up
506
+
507
+ if result_container.exception is not None:
508
+ raise result_container.exception
509
+
510
+ # Use cast since we know the result should be T if no exception occurred
511
+ return cast(T, result_container.result)
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,70 @@
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 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
+ app.registered_commands.append(
42
+ CommandInfo(
43
+ name="ws",
44
+ help="Start the Waldiez WebSocket server.",
45
+ callback=serve,
46
+ )
47
+ )
48
+
49
+
50
+ __all__ = [
51
+ "WaldiezWsServer",
52
+ "run_server",
53
+ "ClientManager",
54
+ "ConnectionManager",
55
+ "HealthChecker",
56
+ "ServerHealth",
57
+ "test_server_connection",
58
+ "ErrorHandler",
59
+ "ErrorCode",
60
+ "MessageParsingError",
61
+ "MessageHandlingError",
62
+ "UnsupportedActionError",
63
+ "ServerOverloadError",
64
+ "SessionManager",
65
+ "OperationTimeoutError",
66
+ "WaldiezServerError",
67
+ "get_available_port",
68
+ "is_port_available",
69
+ "add_ws_app",
70
+ ]
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()
@@ -0,0 +1,201 @@
1
+ # SPDX-License-Identifier: Apache-2.0.
2
+ # Copyright (c) 2024 - 2025 Waldiez and contributors.
3
+
4
+ """Files related request handler."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from waldiez.exporter import WaldiezExporter
14
+ from waldiez.models import Waldiez
15
+
16
+ from .models import (
17
+ ConvertWorkflowRequest,
18
+ ConvertWorkflowResponse,
19
+ SaveFlowRequest,
20
+ SaveFlowResponse,
21
+ )
22
+
23
+
24
+ class FileRequestHandler:
25
+ """Handles file-related requests."""
26
+
27
+ @staticmethod
28
+ def handle_save_flow_request(
29
+ msg: SaveFlowRequest,
30
+ workspace_dir: Path,
31
+ client_id: str,
32
+ logger: logging.Logger,
33
+ ) -> dict[str, Any]:
34
+ """Handle save flow request.
35
+
36
+ Parameters
37
+ ----------
38
+ msg : SaveFlowRequest
39
+ The save flow request message.
40
+ workspace_dir : Path
41
+ The workspace directory.
42
+ client_id : str
43
+ The client ID.
44
+ logger : logging.Logger
45
+ The logger instance.
46
+
47
+ Returns
48
+ -------
49
+ dict[str, Any]
50
+ The response dictionary.
51
+ """
52
+ filename = msg.filename or f"waldiez_{client_id}.waldiez"
53
+ try:
54
+ output_path = resolve_output_path(
55
+ filename,
56
+ workspace_dir=workspace_dir,
57
+ expected_ext="waldiez",
58
+ )
59
+ except ValueError as exc:
60
+ logger.error("Error resolving output path: %s", exc)
61
+ return SaveFlowResponse.fail(
62
+ error=f"Invalid output path: {exc}",
63
+ file_path=filename,
64
+ ).model_dump(mode="json")
65
+ # pylint: disable=too-many-try-statements
66
+ try:
67
+ if output_path.exists() and not msg.force_overwrite:
68
+ return SaveFlowResponse.fail(
69
+ error=f"File exists: {output_path}",
70
+ file_path=str(output_path.relative_to(workspace_dir)),
71
+ ).model_dump(mode="json")
72
+
73
+ # Parent dir already created by resolve_output_path
74
+ output_path.write_text(msg.flow_data, encoding="utf-8")
75
+
76
+ return SaveFlowResponse.ok(
77
+ file_path=str(output_path.relative_to(workspace_dir))
78
+ ).model_dump(mode="json")
79
+ except Exception as e: # pylint: disable=broad-exception-caught
80
+ logger.error("Error saving flow: %s", e)
81
+ return SaveFlowResponse.fail(error=str(e)).model_dump(mode="json")
82
+
83
+ @staticmethod
84
+ def handle_convert_workflow_request(
85
+ msg: ConvertWorkflowRequest,
86
+ client_id: str,
87
+ workspace_dir: Path,
88
+ logger: logging.Logger,
89
+ ) -> dict[str, Any]:
90
+ """Handle a convert workflow request.
91
+
92
+ Parameters
93
+ ----------
94
+ msg : ConvertWorkflowRequest
95
+ The convert workflow request message.
96
+ client_id : str
97
+ The client ID.
98
+ workspace_dir : Path
99
+ The workspace directory.
100
+ logger : logging.Logger
101
+ The logger instance.
102
+
103
+ Returns
104
+ -------
105
+ dict[str, Any]
106
+ The response dictionary.
107
+ """
108
+ target_format = (msg.target_format or "").strip().lower()
109
+ if target_format not in {"py", "ipynb"}:
110
+ return ConvertWorkflowResponse.fail(
111
+ error=f"Unsupported target format: {target_format}",
112
+ target_format=target_format,
113
+ ).model_dump(mode="json")
114
+
115
+ try:
116
+ waldiez_data = Waldiez.from_dict(json.loads(msg.flow_data))
117
+ except Exception as e: # pylint: disable=broad-exception-caught
118
+ return ConvertWorkflowResponse.fail(
119
+ error=f"Invalid flow_data: {e}",
120
+ target_format=target_format,
121
+ ).model_dump(mode="json")
122
+
123
+ try:
124
+ # Use normalized target_format for default name
125
+ filename = msg.output_path or f"waldiez_{client_id}.{target_format}"
126
+ output_path = resolve_output_path(
127
+ filename,
128
+ workspace_dir=workspace_dir,
129
+ expected_ext=target_format,
130
+ )
131
+ except ValueError as exc:
132
+ logger.error("Error resolving output path: %s", exc)
133
+ return ConvertWorkflowResponse.fail(
134
+ error=f"Invalid output path: {exc}",
135
+ target_format=target_format,
136
+ ).model_dump(mode="json")
137
+
138
+ try:
139
+ exporter = WaldiezExporter(waldiez_data)
140
+ exporter.export(path=output_path, force=True, structured_io=True)
141
+
142
+ return ConvertWorkflowResponse.ok(
143
+ target_format=target_format,
144
+ output_path=str(output_path.relative_to(workspace_dir)),
145
+ ).model_dump(mode="json")
146
+ except Exception as e: # pylint: disable=broad-exception-caught
147
+ logger.error("Error converting workflow: %s", e)
148
+ return ConvertWorkflowResponse.fail(
149
+ error=str(e), target_format=target_format
150
+ ).model_dump(mode="json")
151
+
152
+
153
+ def resolve_output_path(
154
+ filename: str,
155
+ workspace_dir: Path,
156
+ expected_ext: str | None = None,
157
+ ) -> Path:
158
+ """
159
+ Resolve output path inside the workspace.
160
+
161
+ Parameters
162
+ ----------
163
+ filename : str
164
+ Provided filename (may be relative or absolute).
165
+ workspace_dir : Path
166
+ The workspace directory to resolve the output path against.
167
+ expected_ext : str | None
168
+ If provided, ensure the filename ends with this extension.
169
+
170
+ Returns
171
+ -------
172
+ Path
173
+ Resolved absolute path, with parent directories created.
174
+
175
+ Raises
176
+ ------
177
+ ValueError
178
+ If the output path is outside the workspace.
179
+ """
180
+ # Normalize workspace_dir to an absolute path
181
+ workspace_dir = workspace_dir.resolve()
182
+
183
+ output_path = Path(filename)
184
+ if not output_path.is_absolute():
185
+ output_path = workspace_dir / output_path
186
+
187
+ if expected_ext and output_path.suffix != f".{expected_ext}":
188
+ output_path = output_path.with_suffix(f".{expected_ext}")
189
+
190
+ output_path = output_path.resolve()
191
+
192
+ # Ensure output_path is a subpath of workspace_dir
193
+ try:
194
+ output_path.relative_to(workspace_dir)
195
+ except ValueError as exc:
196
+ raise ValueError(
197
+ f"Output path {output_path} is outside workspace {workspace_dir}"
198
+ ) from exc
199
+
200
+ output_path.parent.mkdir(parents=True, exist_ok=True)
201
+ return output_path