waldiez 0.6.0__py3-none-any.whl → 0.6.1__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 (188) hide show
  1. waldiez/__init__.py +1 -1
  2. waldiez/_version.py +1 -1
  3. waldiez/cli.py +18 -7
  4. waldiez/cli_extras/jupyter.py +3 -0
  5. waldiez/cli_extras/runner.py +3 -1
  6. waldiez/cli_extras/studio.py +3 -1
  7. waldiez/exporter.py +9 -3
  8. waldiez/exporting/agent/exporter.py +9 -10
  9. waldiez/exporting/agent/extras/captain_agent_extras.py +6 -6
  10. waldiez/exporting/agent/extras/doc_agent_extras.py +6 -6
  11. waldiez/exporting/agent/extras/group_manager_agent_extas.py +34 -23
  12. waldiez/exporting/agent/extras/group_member_extras.py +6 -5
  13. waldiez/exporting/agent/extras/handoffs/after_work.py +1 -1
  14. waldiez/exporting/agent/extras/handoffs/available.py +1 -1
  15. waldiez/exporting/agent/extras/handoffs/condition.py +3 -2
  16. waldiez/exporting/agent/extras/handoffs/handoff.py +1 -1
  17. waldiez/exporting/agent/extras/handoffs/target.py +6 -4
  18. waldiez/exporting/agent/extras/rag/chroma_extras.py +27 -19
  19. waldiez/exporting/agent/extras/rag/mongo_extras.py +8 -8
  20. waldiez/exporting/agent/extras/rag/pgvector_extras.py +5 -5
  21. waldiez/exporting/agent/extras/rag/qdrant_extras.py +5 -4
  22. waldiez/exporting/agent/extras/rag/vector_db_extras.py +1 -1
  23. waldiez/exporting/agent/extras/rag_user_proxy_agent_extras.py +5 -7
  24. waldiez/exporting/agent/extras/reasoning_agent_extras.py +3 -5
  25. waldiez/exporting/chats/exporter.py +4 -4
  26. waldiez/exporting/chats/processor.py +1 -2
  27. waldiez/exporting/chats/utils/common.py +89 -48
  28. waldiez/exporting/chats/utils/group.py +9 -9
  29. waldiez/exporting/chats/utils/nested.py +7 -7
  30. waldiez/exporting/chats/utils/sequential.py +1 -1
  31. waldiez/exporting/chats/utils/single.py +2 -2
  32. waldiez/exporting/core/content.py +7 -7
  33. waldiez/exporting/core/context.py +5 -3
  34. waldiez/exporting/core/exporter.py +5 -3
  35. waldiez/exporting/core/exporters.py +2 -2
  36. waldiez/exporting/core/extras/agent_extras/captain_extras.py +2 -2
  37. waldiez/exporting/core/extras/agent_extras/group_manager_extras.py +2 -2
  38. waldiez/exporting/core/extras/agent_extras/rag_user_extras.py +2 -2
  39. waldiez/exporting/core/extras/agent_extras/standard_extras.py +3 -8
  40. waldiez/exporting/core/extras/base.py +7 -5
  41. waldiez/exporting/core/extras/flow_extras.py +4 -5
  42. waldiez/exporting/core/extras/model_extras.py +2 -2
  43. waldiez/exporting/core/extras/path_resolver.py +1 -2
  44. waldiez/exporting/core/extras/serializer.py +2 -2
  45. waldiez/exporting/core/protocols.py +6 -5
  46. waldiez/exporting/core/result.py +25 -28
  47. waldiez/exporting/core/types.py +10 -10
  48. waldiez/exporting/core/utils/llm_config.py +2 -2
  49. waldiez/exporting/core/validation.py +10 -11
  50. waldiez/exporting/flow/execution_generator.py +98 -10
  51. waldiez/exporting/flow/exporter.py +2 -2
  52. waldiez/exporting/flow/factory.py +2 -2
  53. waldiez/exporting/flow/file_generator.py +4 -2
  54. waldiez/exporting/flow/merger.py +5 -3
  55. waldiez/exporting/flow/orchestrator.py +72 -2
  56. waldiez/exporting/flow/utils/common.py +5 -5
  57. waldiez/exporting/flow/utils/importing.py +6 -7
  58. waldiez/exporting/flow/utils/linting.py +25 -9
  59. waldiez/exporting/flow/utils/logging.py +2 -2
  60. waldiez/exporting/models/exporter.py +8 -8
  61. waldiez/exporting/models/processor.py +5 -5
  62. waldiez/exporting/tools/exporter.py +2 -2
  63. waldiez/exporting/tools/processor.py +7 -4
  64. waldiez/io/__init__.py +8 -4
  65. waldiez/io/_ws.py +10 -6
  66. waldiez/io/models/constants.py +10 -10
  67. waldiez/io/models/content/audio.py +1 -0
  68. waldiez/io/models/content/base.py +20 -18
  69. waldiez/io/models/content/file.py +1 -0
  70. waldiez/io/models/content/image.py +1 -0
  71. waldiez/io/models/content/text.py +1 -0
  72. waldiez/io/models/content/video.py +1 -0
  73. waldiez/io/models/user_input.py +10 -5
  74. waldiez/io/models/user_response.py +17 -16
  75. waldiez/io/mqtt.py +18 -31
  76. waldiez/io/redis.py +18 -22
  77. waldiez/io/structured.py +52 -53
  78. waldiez/io/utils.py +3 -0
  79. waldiez/io/ws.py +5 -1
  80. waldiez/logger.py +16 -3
  81. waldiez/models/agents/__init__.py +3 -0
  82. waldiez/models/agents/agent/agent.py +23 -16
  83. waldiez/models/agents/agent/agent_data.py +25 -22
  84. waldiez/models/agents/agent/code_execution.py +9 -11
  85. waldiez/models/agents/agent/termination_message.py +10 -12
  86. waldiez/models/agents/agent/update_system_message.py +2 -4
  87. waldiez/models/agents/agents.py +8 -8
  88. waldiez/models/agents/assistant/assistant.py +6 -3
  89. waldiez/models/agents/assistant/assistant_data.py +2 -2
  90. waldiez/models/agents/captain/captain_agent.py +7 -4
  91. waldiez/models/agents/captain/captain_agent_data.py +5 -7
  92. waldiez/models/agents/doc_agent/doc_agent.py +7 -4
  93. waldiez/models/agents/doc_agent/doc_agent_data.py +9 -10
  94. waldiez/models/agents/doc_agent/rag_query_engine.py +10 -12
  95. waldiez/models/agents/extra_requirements.py +3 -3
  96. waldiez/models/agents/group_manager/group_manager.py +12 -7
  97. waldiez/models/agents/group_manager/group_manager_data.py +13 -12
  98. waldiez/models/agents/group_manager/speakers.py +17 -19
  99. waldiez/models/agents/rag_user_proxy/rag_user_proxy.py +7 -4
  100. waldiez/models/agents/rag_user_proxy/rag_user_proxy_data.py +4 -1
  101. waldiez/models/agents/rag_user_proxy/retrieve_config.py +69 -63
  102. waldiez/models/agents/rag_user_proxy/vector_db_config.py +19 -19
  103. waldiez/models/agents/reasoning/reasoning_agent.py +7 -4
  104. waldiez/models/agents/reasoning/reasoning_agent_data.py +3 -2
  105. waldiez/models/agents/reasoning/reasoning_agent_reason_config.py +8 -8
  106. waldiez/models/agents/user_proxy/user_proxy.py +6 -3
  107. waldiez/models/agents/user_proxy/user_proxy_data.py +1 -1
  108. waldiez/models/chat/chat.py +27 -20
  109. waldiez/models/chat/chat_data.py +22 -19
  110. waldiez/models/chat/chat_message.py +9 -9
  111. waldiez/models/chat/chat_nested.py +9 -9
  112. waldiez/models/chat/chat_summary.py +6 -6
  113. waldiez/models/common/__init__.py +2 -0
  114. waldiez/models/common/ag2_version.py +2 -0
  115. waldiez/models/common/dict_utils.py +8 -6
  116. waldiez/models/common/handoff.py +18 -17
  117. waldiez/models/common/method_utils.py +7 -7
  118. waldiez/models/common/naming.py +49 -0
  119. waldiez/models/flow/flow.py +11 -6
  120. waldiez/models/flow/flow_data.py +23 -17
  121. waldiez/models/flow/info.py +3 -3
  122. waldiez/models/flow/naming.py +2 -1
  123. waldiez/models/model/_aws.py +11 -13
  124. waldiez/models/model/_llm.py +5 -0
  125. waldiez/models/model/_price.py +2 -4
  126. waldiez/models/model/extra_requirements.py +1 -3
  127. waldiez/models/model/model.py +2 -2
  128. waldiez/models/model/model_data.py +21 -21
  129. waldiez/models/tool/extra_requirements.py +2 -4
  130. waldiez/models/tool/predefined/_duckduckgo.py +1 -0
  131. waldiez/models/tool/predefined/_email.py +1 -0
  132. waldiez/models/tool/predefined/_google.py +1 -0
  133. waldiez/models/tool/predefined/_perplexity.py +1 -0
  134. waldiez/models/tool/predefined/_searxng.py +1 -0
  135. waldiez/models/tool/predefined/_tavily.py +1 -0
  136. waldiez/models/tool/predefined/_wikipedia.py +1 -0
  137. waldiez/models/tool/predefined/_youtube.py +1 -0
  138. waldiez/models/tool/tool.py +8 -5
  139. waldiez/models/tool/tool_data.py +2 -2
  140. waldiez/models/waldiez.py +152 -4
  141. waldiez/runner.py +11 -5
  142. waldiez/running/async_utils.py +192 -0
  143. waldiez/running/base_runner.py +117 -264
  144. waldiez/running/dir_utils.py +52 -0
  145. waldiez/running/environment.py +10 -44
  146. waldiez/running/events_mixin.py +252 -0
  147. waldiez/running/exceptions.py +20 -0
  148. waldiez/running/gen_seq_diagram.py +18 -15
  149. waldiez/running/io_utils.py +216 -0
  150. waldiez/running/protocol.py +11 -5
  151. waldiez/running/requirements_mixin.py +65 -0
  152. waldiez/running/results_mixin.py +926 -0
  153. waldiez/running/standard_runner.py +22 -25
  154. waldiez/running/step_by_step/breakpoints_mixin.py +192 -60
  155. waldiez/running/step_by_step/command_handler.py +3 -0
  156. waldiez/running/step_by_step/events_processor.py +194 -14
  157. waldiez/running/step_by_step/step_by_step_models.py +110 -43
  158. waldiez/running/step_by_step/step_by_step_runner.py +107 -57
  159. waldiez/running/subprocess_runner/__base__.py +9 -1
  160. waldiez/running/subprocess_runner/_async_runner.py +5 -3
  161. waldiez/running/subprocess_runner/_sync_runner.py +6 -2
  162. waldiez/running/subprocess_runner/runner.py +39 -23
  163. waldiez/running/timeline_processor.py +1 -1
  164. waldiez/utils/__init__.py +2 -0
  165. waldiez/utils/conflict_checker.py +4 -4
  166. waldiez/utils/python_manager.py +415 -0
  167. waldiez/ws/_file_handler.py +18 -18
  168. waldiez/ws/_mock.py +2 -1
  169. waldiez/ws/cli.py +36 -12
  170. waldiez/ws/client_manager.py +35 -27
  171. waldiez/ws/errors.py +3 -0
  172. waldiez/ws/models.py +43 -52
  173. waldiez/ws/reloader.py +12 -4
  174. waldiez/ws/server.py +85 -55
  175. waldiez/ws/session_manager.py +8 -9
  176. waldiez/ws/session_stats.py +1 -1
  177. waldiez/ws/utils.py +4 -1
  178. {waldiez-0.6.0.dist-info → waldiez-0.6.1.dist-info}/METADATA +82 -93
  179. waldiez-0.6.1.dist-info/RECORD +254 -0
  180. waldiez/running/post_run.py +0 -186
  181. waldiez/running/pre_run.py +0 -281
  182. waldiez/running/run_results.py +0 -14
  183. waldiez/running/utils.py +0 -625
  184. waldiez-0.6.0.dist-info/RECORD +0 -251
  185. {waldiez-0.6.0.dist-info → waldiez-0.6.1.dist-info}/WHEEL +0 -0
  186. {waldiez-0.6.0.dist-info → waldiez-0.6.1.dist-info}/entry_points.txt +0 -0
  187. {waldiez-0.6.0.dist-info → waldiez-0.6.1.dist-info}/licenses/LICENSE +0 -0
  188. {waldiez-0.6.0.dist-info → waldiez-0.6.1.dist-info}/licenses/NOTICE.md +0 -0
waldiez/running/utils.py DELETED
@@ -1,625 +0,0 @@
1
- # SPDX-License-Identifier: Apache-2.0.
2
- # Copyright (c) 2024 - 2025 Waldiez and contributors.
3
- """Common utilities for the waldiez runner."""
4
-
5
- import asyncio
6
- import functools
7
- import inspect
8
- import logging
9
- import os
10
- import re
11
- import subprocess
12
- import sys
13
- import threading
14
- import traceback
15
-
16
- # noinspection PyProtectedMember
17
- from asyncio.subprocess import Process
18
- from contextlib import asynccontextmanager, contextmanager
19
- from dataclasses import dataclass
20
- from getpass import getpass
21
- from pathlib import Path
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
44
-
45
-
46
- @dataclass
47
- class ProcessSetup:
48
- """Container for subprocess setup data."""
49
-
50
- temp_dir: Path
51
- file_path: Path
52
- old_vars: dict[str, str]
53
- skip_mmd: bool
54
-
55
-
56
- @contextmanager
57
- def chdir(to: Union[str, Path]) -> Iterator[None]:
58
- """Change the current working directory in a context.
59
-
60
- Parameters
61
- ----------
62
- to : Union[str, Path]
63
- The directory to change to.
64
-
65
- Yields
66
- ------
67
- Iterator[None]
68
- The context manager.
69
- """
70
- old_cwd = str(os.getcwd())
71
- os.chdir(to)
72
- try:
73
- yield
74
- finally:
75
- os.chdir(old_cwd)
76
-
77
-
78
- @asynccontextmanager
79
- async def a_chdir(to: Union[str, Path]) -> AsyncIterator[None]:
80
- """Asynchronously change the current working directory in a context.
81
-
82
- Parameters
83
- ----------
84
- to : Union[str, Path]
85
- The directory to change to.
86
-
87
- Yields
88
- ------
89
- AsyncIterator[None]
90
- The async context manager.
91
- """
92
- old_cwd = str(os.getcwd())
93
- os.chdir(to)
94
- try:
95
- yield
96
- finally:
97
- os.chdir(old_cwd)
98
-
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
-
165
- def strip_ansi(text: str) -> str:
166
- """Remove ANSI escape sequences from text.
167
-
168
- Parameters
169
- ----------
170
- text : str
171
- The text to strip.
172
-
173
- Returns
174
- -------
175
- str
176
- The text without ANSI escape sequences.
177
- """
178
- ansi_pattern = re.compile(r"\x1b\[[0-9;]*m|\x1b\[.*?[@-~]")
179
- return ansi_pattern.sub("", text)
180
-
181
-
182
- def create_sync_subprocess(setup: ProcessSetup) -> subprocess.Popen[bytes]:
183
- """Create a synchronous subprocess.
184
-
185
- Parameters
186
- ----------
187
- setup : ProcessSetup
188
- The setup data for the subprocess.
189
-
190
- Returns
191
- -------
192
- subprocess.Popen[bytes]
193
- The created subprocess.
194
- """
195
- return subprocess.Popen(
196
- [get_python_executable(), "-u", str(setup.file_path)],
197
- stdout=subprocess.PIPE,
198
- stderr=subprocess.PIPE,
199
- stdin=subprocess.PIPE,
200
- # text=True,
201
- # bufsize=1, # Line buffered for real-time output
202
- # universal_newlines=True,
203
- env={**os.environ},
204
- )
205
-
206
-
207
- async def create_async_subprocess(setup: ProcessSetup) -> Process:
208
- """Create an asynchronous subprocess.
209
-
210
- Parameters
211
- ----------
212
- setup : ProcessSetup
213
- The setup data for the subprocess.
214
-
215
- Returns
216
- -------
217
- Process
218
- The created asynchronous subprocess.
219
- """
220
- return await asyncio.create_subprocess_exec(
221
- get_python_executable(),
222
- "-u",
223
- str(setup.file_path),
224
- # stdout=asyncio.subprocess.PIPE,
225
- # stderr=asyncio.subprocess.PIPE,
226
- # stdin=asyncio.subprocess.PIPE,
227
- env={**os.environ},
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}"