ssh-licco 2.5.2__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {ssh_licco-2.5.2/ssh_licco.egg-info → ssh_licco-2.6.0}/PKG-INFO +1 -1
  2. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/pyproject.toml +2 -1
  3. {ssh_licco-2.5.2 → ssh_licco-2.6.0/ssh_licco.egg-info}/PKG-INFO +1 -1
  4. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/SOURCES.txt +2 -1
  5. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/__init__.py +2 -2
  6. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/execute.py +58 -6
  7. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/server.py +2 -2
  8. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/session_manager.py +50 -0
  9. ssh_licco-2.6.0/tests/test_watchdog.py +181 -0
  10. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/LICENSE +0 -0
  11. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/README.md +0 -0
  12. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/setup.cfg +0 -0
  13. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/dependency_links.txt +0 -0
  14. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/entry_points.txt +0 -0
  15. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/requires.txt +0 -0
  16. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_licco.egg-info/top_level.txt +0 -0
  17. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/__main__.py +0 -0
  18. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/audit_logger.py +0 -0
  19. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/batch_executor.py +0 -0
  20. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/cli.py +0 -0
  21. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/__init__.py +0 -0
  22. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/additional_clients.py +0 -0
  23. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/factory.py +0 -0
  24. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/interface.py +0 -0
  25. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/clients/paramiko_client.py +0 -0
  26. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/config_manager.py +0 -0
  27. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/connection_config.py +0 -0
  28. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/connection_pool.py +0 -0
  29. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/exceptions.py +0 -0
  30. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/executor.py +0 -0
  31. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/__init__.py +0 -0
  32. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/connect.py +0 -0
  33. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/context.py +0 -0
  34. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/docker.py +0 -0
  35. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/file_transfer.py +0 -0
  36. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/host.py +0 -0
  37. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/key.py +0 -0
  38. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/process.py +0 -0
  39. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/schemas.py +0 -0
  40. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/session.py +0 -0
  41. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/handlers/utils.py +0 -0
  42. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/key_manager.py +0 -0
  43. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/logging_config.py +0 -0
  44. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/security.py +0 -0
  45. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/service.py +0 -0
  46. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/tunnel.py +0 -0
  47. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/ssh_mcp/watchdog.py +0 -0
  48. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_config_manager.py +0 -0
  49. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_key_manager.py +0 -0
  50. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_security.py +0 -0
  51. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_server.py +0 -0
  52. {ssh_licco-2.5.2 → ssh_licco-2.6.0}/tests/test_session_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-licco
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
5
5
  Author-email: SSH LICCO Team <contact@ssh-licco.dev>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssh-licco"
3
- version = "2.5.2"
3
+ version = "2.6.0"
4
4
  description = "SSH Model Context Protocol Server - Enable SSH functionality for AI models"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -59,4 +59,5 @@ warn_unused_configs = true
59
59
  disallow_untyped_defs = false
60
60
  [tool.pytest.ini_options]
61
61
  asyncio_mode = "auto"
62
+ asyncio_default_fixture_loop_scope = "function"
62
63
  testpaths = ["tests"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssh-licco
3
- Version: 2.5.2
3
+ Version: 2.6.0
4
4
  Summary: SSH Model Context Protocol Server - Enable SSH functionality for AI models
5
5
  Author-email: SSH LICCO Team <contact@ssh-licco.dev>
6
6
  License: MIT
@@ -46,4 +46,5 @@ tests/test_config_manager.py
46
46
  tests/test_key_manager.py
47
47
  tests/test_security.py
48
48
  tests/test_server.py
49
- tests/test_session_manager.py
49
+ tests/test_session_manager.py
50
+ tests/test_watchdog.py
@@ -1,8 +1,8 @@
1
1
  # SSH-LICCO 版本信息
2
2
  # 所有代码中的版本信息都从这里读取
3
- # 版本号:2.5.2
3
+ # 版本号:2.6.0
4
4
 
5
- __version__ = "2.5.2"
5
+ __version__ = "2.6.0"
6
6
  __author__ = "Li Qi"
7
7
  __email__ = "1985694657@qq.com"
8
8
  __license__ = "MIT"
@@ -9,12 +9,12 @@ import uuid
9
9
 
10
10
  from mcp.types import TextContent
11
11
 
12
- from ..security import (
13
- SecurityError,
14
- command_validator,
15
- format_backup_prompt,
16
- generate_backup_command,
17
- parse_deletion_command,
12
+ from ..security import (
13
+ SecurityError,
14
+ command_validator,
15
+ format_backup_prompt,
16
+ generate_backup_command,
17
+ parse_deletion_command,
18
18
  )
19
19
  from .connect import handle_connect
20
20
  from .context import HandlerContext
@@ -152,9 +152,13 @@ Current security level: {os.getenv("SSH_SECURITY_LEVEL", "balanced")}""",
152
152
  ctx.logger.warning(f"User chose to delete without backup: {command}")
153
153
 
154
154
  if background:
155
+ ctx.logger.info(
156
+ f"[execute] background mode chosen for session={session_id} timeout={timeout}s"
157
+ )
155
158
  return await execute_background(ctx, session_id, command, args, timeout)
156
159
 
157
160
  # Normal execution
161
+ ctx.logger.info(f"[execute] normal mode session={session_id} timeout={timeout}s")
158
162
  session = await ctx.session_manager.get_session(session_id)
159
163
  if not session:
160
164
  return [TextContent(type="text", text=f"Session not found: {session_id}")]
@@ -181,9 +185,17 @@ Current security level: {os.getenv("SSH_SECURITY_LEVEL", "balanced")}""",
181
185
  stdin_data = sudo_pwd + "\n"
182
186
  get_pty = True
183
187
 
188
+ ctx.logger.info(
189
+ f"[execute] invoking session.execute_command for session={session_id} "
190
+ f"timeout={timeout}s get_pty={get_pty}"
191
+ )
184
192
  result = await session.execute_command(
185
193
  command, timeout=timeout, stdin_data=stdin_data, get_pty=get_pty
186
194
  )
195
+ ctx.logger.info(
196
+ f"[execute] session.execute_command returned for session={session_id} "
197
+ f"exit_code={result.get('exit_code')}"
198
+ )
187
199
 
188
200
  if ctx.audit:
189
201
  session_info = await ctx.session_manager.get_session(session_id)
@@ -214,6 +226,10 @@ async def execute_background(
214
226
  ctx: HandlerContext, session_id: str, command: str, args: dict, timeout: int
215
227
  ) -> list[TextContent]:
216
228
  """Execute a command as a background task with proper nohup + bash -c wrapping."""
229
+ ctx.logger.info(
230
+ f"[execute-background] starting task session={session_id} timeout={timeout}s "
231
+ f"command={command[:200]}"
232
+ )
217
233
  workdir = args.get("workdir", "/tmp")
218
234
  log_file = args.get("log_file", "/tmp/background_task.log")
219
235
  wait = args.get("wait", False)
@@ -298,10 +314,22 @@ async def execute_background(
298
314
  try:
299
315
  # Execute as a single command (background=False) — the wrapper
300
316
  # includes sleep + status check, so it returns in ~1 second.
317
+ ctx.logger.info(
318
+ f"[execute-background] launching nohup wrapper for session={session_id} "
319
+ f"task_id={task_id} timeout={timeout + 5}s"
320
+ )
301
321
  result = await ctx.session_manager.execute_command(
302
322
  session_id, check_cmd, timeout=timeout + 5, background=False
303
323
  )
324
+ ctx.logger.info(
325
+ f"[execute-background] wrapper returned for session={session_id} "
326
+ f"task_id={task_id} exit_code={result.get('exit_code')}"
327
+ )
304
328
  except Exception as e:
329
+ ctx.logger.error(
330
+ f"[execute-background] failed to start task for session={session_id} "
331
+ f"task_id={task_id}: {type(e).__name__}: {e}"
332
+ )
305
333
  return [TextContent(type="text", text=f"Error starting background task: {str(e)}")]
306
334
 
307
335
  start_stdout = (result.get("stdout") or "").strip()
@@ -328,6 +356,11 @@ async def execute_background(
328
356
  elif in_log:
329
357
  log_tail += line + "\n"
330
358
 
359
+ ctx.logger.info(
360
+ f"[execute-background] parsed status for session={session_id} "
361
+ f"task_id={task_id} pid={pid} status={status} exit_code_str={exit_code_str}"
362
+ )
363
+
331
364
  # ── 状态判定:区分 5 种情况 ──
332
365
  #
333
366
  # 1. ✅ SUCCESS — STATUS=COMPLETED, EXIT=0 命令执行成功
@@ -540,6 +573,11 @@ async def wait_for_task_completion(
540
573
  elapsed = 0
541
574
  interval = 2
542
575
 
576
+ ctx.logger.info(
577
+ f"[wait-task] start waiting session={session_id} task_id={task_id} "
578
+ f"timeout={timeout}s log_file={log_file}"
579
+ )
580
+
543
581
  while elapsed < timeout:
544
582
  await asyncio.sleep(interval)
545
583
  elapsed += interval
@@ -552,6 +590,12 @@ async def wait_for_task_completion(
552
590
  result = await ctx.session_manager.execute_command(session_id, check_cmd, timeout=10)
553
591
  status = result.get("stdout", "").strip()
554
592
 
593
+ if elapsed % 10 == 0:
594
+ ctx.logger.debug(
595
+ f"[wait-task] heartbeat session={session_id} task_id={task_id} "
596
+ f"elapsed={elapsed}s status={status}"
597
+ )
598
+
555
599
  if status == "COMPLETED" or status == "NOT_FOUND":
556
600
  # 读取退出码
557
601
  exit_result = await ctx.session_manager.execute_command(
@@ -569,6 +613,10 @@ async def wait_for_task_completion(
569
613
  status_label = (
570
614
  "Completed Successfully" if exit_code == 0 else f"Failed (exit code {exit_code})"
571
615
  )
616
+ ctx.logger.info(
617
+ f"[wait-task] completed session={session_id} task_id={task_id} "
618
+ f"elapsed={elapsed}s exit_code={exit_code}"
619
+ )
572
620
  return f"""Task {status_label}!
573
621
 
574
622
  Task ID: {task_id}
@@ -581,6 +629,10 @@ Command Output:
581
629
  {log_content}
582
630
  """
583
631
 
632
+ ctx.logger.warning(
633
+ f"[wait-task] timeout session={session_id} task_id={task_id} "
634
+ f"elapsed={elapsed}s timeout={timeout}s"
635
+ )
584
636
  return f"""Task Still Running (Timeout)
585
637
 
586
638
  Task ID: {task_id}
@@ -1,7 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import logging
5
4
  import os
6
5
  import shlex
7
6
  import threading
@@ -20,6 +19,7 @@ from .connection_config import ConnectionConfig
20
19
  from .handlers import HANDLERS, schemas
21
20
  from .handlers.context import HandlerContext
22
21
  from .key_manager import KeyManager
22
+ from .logging_config import get_logger
23
23
  from .session_manager import SessionManager
24
24
 
25
25
  try:
@@ -27,7 +27,7 @@ try:
27
27
  except Exception:
28
28
  from . import __version__
29
29
 
30
- logger = logging.getLogger(__name__)
30
+ logger = get_logger(__name__)
31
31
 
32
32
 
33
33
  class Tunnel:
@@ -210,7 +210,33 @@ class SSHSession:
210
210
  async with self._connect_lock:
211
211
  self._state = SessionState.EXECUTING
212
212
  self._last_activity = datetime.now()
213
+ start_time = asyncio.get_event_loop().time()
214
+ heartbeat_task = None
215
+ truncated_cmd = command[:200].replace("\n", " ")
216
+
213
217
  try:
218
+ self._logger.info(
219
+ f"[cmd-start] session={self.session_id} "
220
+ f"host={self.config.host}:{self.config.port} "
221
+ f"timeout={timeout}s background={background} get_pty={get_pty} "
222
+ f"command={truncated_cmd}"
223
+ )
224
+
225
+ async def _heartbeat() -> None:
226
+ try:
227
+ elapsed = 0
228
+ while True:
229
+ await asyncio.sleep(10)
230
+ elapsed += 10
231
+ self._logger.debug(
232
+ f"[cmd-heartbeat] session={self.session_id} "
233
+ f"elapsed={elapsed}s still executing: {truncated_cmd}"
234
+ )
235
+ except asyncio.CancelledError:
236
+ pass
237
+
238
+ heartbeat_task = asyncio.create_task(_heartbeat())
239
+
214
240
  result = await self._executor.submit(
215
241
  self._execute_command_sync,
216
242
  command,
@@ -220,18 +246,42 @@ class SSHSession:
220
246
  get_pty,
221
247
  timeout=timeout + 5,
222
248
  )
249
+
250
+ elapsed = asyncio.get_event_loop().time() - start_time
251
+ self._logger.info(
252
+ f"[cmd-done] session={self.session_id} elapsed={elapsed:.1f}s "
253
+ f"exit_code={result.get('exit_code')} command={truncated_cmd}"
254
+ )
223
255
  return result
256
+ except asyncio.TimeoutError:
257
+ elapsed = asyncio.get_event_loop().time() - start_time
258
+ self._logger.error(
259
+ f"[cmd-timeout] session={self.session_id} elapsed={elapsed:.1f}s "
260
+ f"timeout={timeout}s command={truncated_cmd}"
261
+ )
262
+ raise
224
263
  except ConnectionError:
225
264
  # 连接错误,标记为断开状态
226
265
  self._state = SessionState.DISCONNECTED
227
266
  raise
228
267
  except Exception as e:
268
+ elapsed = asyncio.get_event_loop().time() - start_time
269
+ self._logger.error(
270
+ f"[cmd-error] session={self.session_id} elapsed={elapsed:.1f}s "
271
+ f"error={type(e).__name__}: {e} command={truncated_cmd}"
272
+ )
229
273
  # 其他异常也检查连接状态
230
274
  if not self.is_connected:
231
275
  self._state = SessionState.DISCONNECTED
232
276
  raise ConnectionError(f"SSH connection lost during command execution: {str(e)}")
233
277
  raise
234
278
  finally:
279
+ if heartbeat_task is not None:
280
+ heartbeat_task.cancel()
281
+ try:
282
+ await heartbeat_task
283
+ except asyncio.CancelledError:
284
+ pass
235
285
  if self.is_connected:
236
286
  self._state = SessionState.CONNECTED
237
287
 
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import threading
5
+ from datetime import datetime
6
+ from unittest.mock import AsyncMock, MagicMock, patch
7
+
8
+ import pytest
9
+
10
+ from ssh_mcp.watchdog import (
11
+ GlobalExceptionHandler,
12
+ Watchdog,
13
+ WatchdogEvent,
14
+ WatchdogStatus,
15
+ get_watchdog,
16
+ setup_global_exception_handler,
17
+ )
18
+
19
+
20
+ class TestWatchdogStatus:
21
+ def test_values(self):
22
+ assert WatchdogStatus.RUNNING.value == "running"
23
+ assert WatchdogStatus.STOPPED.value == "stopped"
24
+ assert WatchdogStatus.PAUSED.value == "paused"
25
+
26
+
27
+ class TestWatchdogEvent:
28
+ def test_creation(self):
29
+ event = WatchdogEvent(
30
+ timestamp=datetime.now(),
31
+ event_type="TEST",
32
+ message="test event",
33
+ )
34
+ assert event.event_type == "TEST"
35
+ assert event.message == "test event"
36
+ assert event.details == {}
37
+
38
+ def test_with_details(self):
39
+ event = WatchdogEvent(
40
+ timestamp=datetime.now(),
41
+ event_type="TEST",
42
+ message="test",
43
+ details={"key": "value"},
44
+ )
45
+ assert event.details["key"] == "value"
46
+
47
+
48
+ class TestWatchdog:
49
+ def setup_method(self):
50
+ Watchdog._instance = None
51
+
52
+ def _fresh_watchdog(self) -> Watchdog:
53
+ Watchdog._instance = None
54
+ return Watchdog()
55
+
56
+ def test_singleton(self):
57
+ w1 = Watchdog()
58
+ w2 = Watchdog()
59
+ assert w1 is w2
60
+
61
+ def test_initial_status_stopped(self):
62
+ w = self._fresh_watchdog()
63
+ assert w.status == WatchdogStatus.STOPPED
64
+
65
+ def test_register_task(self):
66
+ w = self._fresh_watchdog()
67
+ w.register_task("task-1", "Test Task", timeout=60)
68
+ tasks = w.get_tasks()
69
+ assert len(tasks) == 1
70
+ assert tasks[0].task_id == "task-1"
71
+
72
+ def test_unregister_task(self):
73
+ w = self._fresh_watchdog()
74
+ w.register_task("task-1", "Test Task")
75
+ w.unregister_task("task-1")
76
+ assert len(w.get_tasks()) == 0
77
+
78
+ def test_update_heartbeat(self):
79
+ w = self._fresh_watchdog()
80
+ w.register_task("task-1", "Test Task")
81
+ old_hb = w.get_tasks()[0].last_heartbeat
82
+ w.update_task_heartbeat("task-1")
83
+ new_hb = w.get_tasks()[0].last_heartbeat
84
+ assert new_hb >= old_hb
85
+
86
+ def test_update_heartbeat_nonexistent(self):
87
+ w = self._fresh_watchdog()
88
+ w.update_task_heartbeat("nonexistent")
89
+
90
+ def test_update_progress(self):
91
+ w = self._fresh_watchdog()
92
+ w.register_task("task-1", "Test Task")
93
+ w.update_task_progress("task-1", 50)
94
+ assert w.get_tasks()[0].progress == 50
95
+
96
+ def test_capture_exception(self):
97
+ w = self._fresh_watchdog()
98
+ w.capture_exception(ValueError("test error"), context="test")
99
+ events = w.get_events()
100
+ assert len(events) >= 1
101
+ assert events[0].event_type == "SYSTEM_ERROR"
102
+
103
+ def test_get_events_limit(self):
104
+ w = self._fresh_watchdog()
105
+ for i in range(5):
106
+ w.capture_exception(ValueError(f"error {i}"))
107
+ events = w.get_events(limit=3)
108
+ assert len(events) <= 3
109
+
110
+ def test_add_recovery_callback(self):
111
+ w = self._fresh_watchdog()
112
+ callback = MagicMock()
113
+ w.add_recovery_callback(callback)
114
+ assert callback in w._recovery_callbacks
115
+
116
+ def test_start_sets_running(self):
117
+ w = self._fresh_watchdog()
118
+ with patch.object(w, "_monitor_loop", MagicMock()):
119
+ with patch("asyncio.create_task", return_value=MagicMock()):
120
+ w.start()
121
+ assert w.status == WatchdogStatus.RUNNING
122
+ w.stop()
123
+
124
+ def test_stop_sets_stopped(self):
125
+ w = self._fresh_watchdog()
126
+ with patch.object(w, "_monitor_loop", MagicMock()):
127
+ with patch("asyncio.create_task", return_value=MagicMock()):
128
+ w.start()
129
+ w.stop()
130
+ assert w.status == WatchdogStatus.STOPPED
131
+
132
+ def test_start_already_running(self):
133
+ w = self._fresh_watchdog()
134
+ with patch.object(w, "_monitor_loop", MagicMock()):
135
+ with patch("asyncio.create_task", return_value=MagicMock()):
136
+ w.start()
137
+ with patch.object(w, "_monitor_loop", MagicMock()):
138
+ with patch("asyncio.create_task", return_value=MagicMock()):
139
+ w.start()
140
+ assert w.status == WatchdogStatus.RUNNING
141
+ w.stop()
142
+
143
+
144
+ class TestGlobalExceptionHandler:
145
+ def test_enable(self):
146
+ handler = GlobalExceptionHandler()
147
+ mock_loop = MagicMock()
148
+ mock_loop.set_exception_handler = MagicMock()
149
+ mock_loop.get_exception_handler = MagicMock(return_value=None)
150
+ with patch("asyncio.get_event_loop", return_value=mock_loop):
151
+ handler.enable()
152
+ assert handler._enabled is True
153
+
154
+ def test_disable(self):
155
+ handler = GlobalExceptionHandler()
156
+ mock_loop = MagicMock()
157
+ mock_loop.set_exception_handler = MagicMock()
158
+ mock_loop.get_exception_handler = MagicMock(return_value=None)
159
+ mock_loop.set_exception_handler = MagicMock()
160
+ with patch("asyncio.get_event_loop", return_value=mock_loop):
161
+ handler.enable()
162
+ handler.disable()
163
+ assert handler._enabled is False
164
+
165
+ def test_disable_not_enabled(self):
166
+ handler = GlobalExceptionHandler()
167
+ handler.disable()
168
+ assert handler._enabled is False
169
+
170
+ def test_enable_already_enabled(self):
171
+ handler = GlobalExceptionHandler()
172
+ handler._enabled = True
173
+ handler.enable()
174
+ assert handler._enabled is True
175
+
176
+
177
+ class TestGetWatchdog:
178
+ def test_returns_watchdog(self):
179
+ Watchdog._instance = None
180
+ w = get_watchdog()
181
+ assert isinstance(w, Watchdog)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes