clauder 0.1.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.
- clauder-0.1.0/.gitignore +16 -0
- clauder-0.1.0/PKG-INFO +30 -0
- clauder-0.1.0/README.md +20 -0
- clauder-0.1.0/pyproject.toml +14 -0
- clauder-0.1.0/src/clauder/__init__.py +13 -0
- clauder-0.1.0/src/clauder/discovery.py +166 -0
- clauder-0.1.0/src/clauder/server.py +420 -0
- clauder-0.1.0/tests/test_clauder.py +266 -0
clauder-0.1.0/.gitignore
ADDED
clauder-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: clauder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Connect AI agents to a live Python session (Positron, Jupyter, VS Code, or a bare REPL) over the ClaudeR protocol
|
|
5
|
+
Author: Nykko Vitali
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: data-science,jupyter,llm,mcp,positron
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# clauder
|
|
12
|
+
|
|
13
|
+
Expose a live Python session to MCP-based AI agents. The Python half of
|
|
14
|
+
[ClaudeR](https://github.com/IMNMV/ClaudeR).
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import clauder
|
|
18
|
+
clauder.start()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The agent then executes code in this session, where your loaded data and
|
|
22
|
+
fitted models already are, and sees the figures it draws. Every line it runs
|
|
23
|
+
is logged and attributed. Works in Positron, Jupyter, VS Code, and a bare
|
|
24
|
+
REPL.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
clauder.start(session_name = "analysis", port = 8790)
|
|
28
|
+
clauder.status()
|
|
29
|
+
clauder.stop()
|
|
30
|
+
```
|
clauder-0.1.0/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# clauder
|
|
2
|
+
|
|
3
|
+
Expose a live Python session to MCP-based AI agents. The Python half of
|
|
4
|
+
[ClaudeR](https://github.com/IMNMV/ClaudeR).
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import clauder
|
|
8
|
+
clauder.start()
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The agent then executes code in this session, where your loaded data and
|
|
12
|
+
fitted models already are, and sees the figures it draws. Every line it runs
|
|
13
|
+
is logged and attributed. Works in Positron, Jupyter, VS Code, and a bare
|
|
14
|
+
REPL.
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
clauder.start(session_name = "analysis", port = 8790)
|
|
18
|
+
clauder.status()
|
|
19
|
+
clauder.stop()
|
|
20
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "clauder"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Connect AI agents to a live Python session (Positron, Jupyter, VS Code, or a bare REPL) over the ClaudeR protocol"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
authors = [{ name = "Nykko Vitali" }]
|
|
8
|
+
keywords = ["mcp", "llm", "positron", "jupyter", "data-science"]
|
|
9
|
+
license = { text = "MIT" }
|
|
10
|
+
dependencies = []
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""clauder: expose a live Python session to MCP agents.
|
|
2
|
+
|
|
3
|
+
import clauder
|
|
4
|
+
clauder.start()
|
|
5
|
+
|
|
6
|
+
Speaks the same protocol as the ClaudeR R addin, so one MCP bridge reaches
|
|
7
|
+
both an R session and a Python session, and an agent picks either by name.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .server import start, status, stop
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
__all__ = ["start", "stop", "status"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Discovery records, byte-compatible with the R side.
|
|
2
|
+
|
|
3
|
+
Both languages write into ~/.claude_r_sessions so one bridge can see every
|
|
4
|
+
session regardless of which one it is. The record shape, the lock protocol and
|
|
5
|
+
the atomic replace all match R/ui.R deliberately. The only addition is the
|
|
6
|
+
`language` field, which is absent in records written by R before this existed
|
|
7
|
+
and therefore means "r" when missing.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
# Frozen shared contract. Do not rename these.
|
|
21
|
+
DISCOVERY_DIRNAME = ".claude_r_sessions"
|
|
22
|
+
TOKEN_HEADER = "X-Clauder-Token"
|
|
23
|
+
|
|
24
|
+
_BAD_NAME = re.compile(r'[\\/:<>"|?*\x00-\x1f]')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def home_dir() -> str:
|
|
28
|
+
if sys.platform == "win32":
|
|
29
|
+
return os.environ.get("USERPROFILE") or os.path.expanduser("~")
|
|
30
|
+
return os.path.expanduser("~")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def discovery_dir() -> str:
|
|
34
|
+
return os.path.join(home_dir(), DISCOVERY_DIRNAME)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def discovery_path(session_name: str) -> str:
|
|
38
|
+
if (not session_name or session_name in (".", "..")
|
|
39
|
+
or _BAD_NAME.search(session_name)):
|
|
40
|
+
raise ValueError("Invalid discovery session name")
|
|
41
|
+
return os.path.join(discovery_dir(), session_name + ".json")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def pid_is_alive(pid: Any) -> Optional[bool]:
|
|
45
|
+
"""True, False, or None for "cannot tell".
|
|
46
|
+
|
|
47
|
+
None matters. A permission error must not be read as permission to delete
|
|
48
|
+
another session's record, which is why this is tri-state like the R side.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
pid = int(pid)
|
|
52
|
+
except (TypeError, ValueError):
|
|
53
|
+
return None
|
|
54
|
+
if pid <= 0:
|
|
55
|
+
return None
|
|
56
|
+
if sys.platform == "win32":
|
|
57
|
+
try:
|
|
58
|
+
import ctypes
|
|
59
|
+
SYNCHRONIZE = 0x00100000
|
|
60
|
+
h = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, pid)
|
|
61
|
+
if h:
|
|
62
|
+
ctypes.windll.kernel32.CloseHandle(h)
|
|
63
|
+
return True
|
|
64
|
+
return False if ctypes.windll.kernel32.GetLastError() == 87 else None
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
os.kill(pid, 0)
|
|
69
|
+
return True
|
|
70
|
+
except ProcessLookupError:
|
|
71
|
+
return False
|
|
72
|
+
except PermissionError:
|
|
73
|
+
return True # alive, owned by someone else
|
|
74
|
+
except Exception:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _Lock:
|
|
79
|
+
"""Directory lock, the same mechanism R uses, so the two interoperate."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, path: str):
|
|
82
|
+
self.lock = path + ".lock"
|
|
83
|
+
|
|
84
|
+
def __enter__(self):
|
|
85
|
+
try:
|
|
86
|
+
os.mkdir(self.lock, 0o700)
|
|
87
|
+
except FileExistsError:
|
|
88
|
+
raise RuntimeError(
|
|
89
|
+
"Discovery record is locked; do not remove another writer's lock"
|
|
90
|
+
) from None
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def __exit__(self, *exc):
|
|
94
|
+
try:
|
|
95
|
+
os.rmdir(self.lock)
|
|
96
|
+
except OSError:
|
|
97
|
+
pass
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def write_record(session_name: str, port: int, token: str,
|
|
102
|
+
plot_auto: bool = True, tool_sets: Any = "all") -> str:
|
|
103
|
+
d = discovery_dir()
|
|
104
|
+
os.makedirs(d, mode=0o700, exist_ok=True)
|
|
105
|
+
f = discovery_path(session_name)
|
|
106
|
+
with _Lock(f):
|
|
107
|
+
other = None
|
|
108
|
+
if os.path.exists(f):
|
|
109
|
+
try:
|
|
110
|
+
with open(f) as fh:
|
|
111
|
+
other = json.load(fh)
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"Unreadable discovery record at %s (%s). Inspect or remove "
|
|
115
|
+
"it, then start the server again." % (f, e)
|
|
116
|
+
) from None
|
|
117
|
+
if other is not None and not isinstance(other, dict):
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
"Unreadable discovery identity in %s; explicit inspection required" % f)
|
|
120
|
+
if other is not None and int(other.get("pid", -1)) != os.getpid() \
|
|
121
|
+
and pid_is_alive(other.get("pid")) is not False:
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"Session name '%s' is already owned by a live or unknown process"
|
|
124
|
+
% session_name)
|
|
125
|
+
|
|
126
|
+
same_owner = (other is not None
|
|
127
|
+
and int(other.get("pid", -1)) == os.getpid()
|
|
128
|
+
and other.get("token") == token)
|
|
129
|
+
started = (other.get("started_at") if same_owner and other.get("started_at")
|
|
130
|
+
else time.strftime("%Y-%m-%dT%H:%M:%S"))
|
|
131
|
+
|
|
132
|
+
info: Dict[str, Any] = {
|
|
133
|
+
"session_name": session_name,
|
|
134
|
+
"port": int(port),
|
|
135
|
+
"pid": os.getpid(),
|
|
136
|
+
"token": token,
|
|
137
|
+
"started_at": started,
|
|
138
|
+
"plot_auto": bool(plot_auto),
|
|
139
|
+
"tool_sets": tool_sets,
|
|
140
|
+
"language": "python",
|
|
141
|
+
}
|
|
142
|
+
fd, tmp = tempfile.mkstemp(prefix=".discovery-", dir=d)
|
|
143
|
+
try:
|
|
144
|
+
with os.fdopen(fd, "w") as fh:
|
|
145
|
+
json.dump(info, fh, indent=2)
|
|
146
|
+
os.chmod(tmp, 0o600)
|
|
147
|
+
os.replace(tmp, f) # atomic, including on Windows
|
|
148
|
+
finally:
|
|
149
|
+
if os.path.exists(tmp):
|
|
150
|
+
os.unlink(tmp)
|
|
151
|
+
return f
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def remove_record(session_name: str) -> None:
|
|
155
|
+
"""Delete our own record only. Never another process's."""
|
|
156
|
+
f = discovery_path(session_name)
|
|
157
|
+
if not os.path.exists(f):
|
|
158
|
+
return
|
|
159
|
+
with _Lock(f):
|
|
160
|
+
try:
|
|
161
|
+
with open(f) as fh:
|
|
162
|
+
info = json.load(fh)
|
|
163
|
+
except Exception:
|
|
164
|
+
return
|
|
165
|
+
if isinstance(info, dict) and int(info.get("pid", -1)) == os.getpid():
|
|
166
|
+
os.unlink(f)
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""The HTTP server that runs inside the user's Python session.
|
|
2
|
+
|
|
3
|
+
Speaks the same wire protocol as the R addin, so the existing bridge needs no
|
|
4
|
+
new transport. POST {code, agent_id, want_plot} and get back
|
|
5
|
+
{success, output, plot, error}. GET returns a status object.
|
|
6
|
+
|
|
7
|
+
Execution goes through IPython's run_cell when a shell is present, so output,
|
|
8
|
+
display hooks and the IDE's plot pane behave exactly as if the user had typed
|
|
9
|
+
the code. Outside IPython it falls back to exec against a persistent namespace.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import secrets
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import traceback
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from .discovery import TOKEN_HEADER, remove_record, write_record
|
|
26
|
+
|
|
27
|
+
MAX_OUTPUT_CHARS = 100_000
|
|
28
|
+
|
|
29
|
+
# Frozen log markers, matching R so one log file holds both sides and the
|
|
30
|
+
# existing parsers keep working.
|
|
31
|
+
_LOG_HEADER = "# --- [%s] ---\n# Code executed by %s:\n%s\n\n"
|
|
32
|
+
_LOG_OUTPUT = "#> "
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _State:
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self.httpd: Optional[HTTPServer] = None
|
|
38
|
+
self.thread: Optional[threading.Thread] = None
|
|
39
|
+
self.session_name: str = "default"
|
|
40
|
+
self.port: Optional[int] = None
|
|
41
|
+
self.token: Optional[str] = None
|
|
42
|
+
self.require_token: bool = False
|
|
43
|
+
self.plot_auto: bool = True
|
|
44
|
+
self.log_path: Optional[str] = None
|
|
45
|
+
self.print_to_console: bool = True
|
|
46
|
+
self.execution_count: int = 0
|
|
47
|
+
self.agents: List[str] = []
|
|
48
|
+
self.namespace: Dict[str, Any] = {}
|
|
49
|
+
# R is single threaded and serialises on its event loop. Python can be
|
|
50
|
+
# re-entered from the server thread, so serialise here instead.
|
|
51
|
+
self.exec_lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
STATE = _State()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _shell():
|
|
58
|
+
try:
|
|
59
|
+
from IPython import get_ipython
|
|
60
|
+
return get_ipython()
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _log(code: str, agent: str) -> None:
|
|
66
|
+
"""Write the code entry. Called before execution, so a hang still leaves
|
|
67
|
+
a record of what was running."""
|
|
68
|
+
if not STATE.log_path:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
with open(STATE.log_path, "a") as fh:
|
|
72
|
+
fh.write(_LOG_HEADER % (time.strftime("%Y-%m-%d %H:%M:%S"), agent, code))
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _log_output(output: str) -> None:
|
|
78
|
+
"""Append what the code printed, under the entry just written for it.
|
|
79
|
+
The "#> " prefix marks these lines as output rather than code, which is
|
|
80
|
+
what the R-side log parsers already skip."""
|
|
81
|
+
if not STATE.log_path or not output.strip():
|
|
82
|
+
return
|
|
83
|
+
try:
|
|
84
|
+
lines = output.rstrip("\n").split("\n")
|
|
85
|
+
if len(lines) > 40:
|
|
86
|
+
lines = lines[:40] + ["... %d more lines not logged" % (len(lines) - 40)]
|
|
87
|
+
with open(STATE.log_path, "a") as fh:
|
|
88
|
+
fh.write("".join(_LOG_OUTPUT + l + "\n" for l in lines) + "\n")
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _Tee(io.TextIOBase):
|
|
94
|
+
"""Capture a copy while the user still sees their own output.
|
|
95
|
+
|
|
96
|
+
This is the Python counterpart of sink(split = TRUE) on the R side.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, real):
|
|
100
|
+
self.real = real
|
|
101
|
+
self.buf = io.StringIO()
|
|
102
|
+
|
|
103
|
+
def write(self, s):
|
|
104
|
+
try:
|
|
105
|
+
self.real.write(s)
|
|
106
|
+
except Exception:
|
|
107
|
+
pass
|
|
108
|
+
return self.buf.write(s)
|
|
109
|
+
|
|
110
|
+
def flush(self):
|
|
111
|
+
try:
|
|
112
|
+
self.real.flush()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _figure_png(fignum: Optional[int] = None) -> Optional[str]:
|
|
118
|
+
"""PNG of the figure the executed code produced.
|
|
119
|
+
|
|
120
|
+
Rendering must go through Agg. An IDE backend may carry no raster
|
|
121
|
+
renderer of its own, and then a plain savefig quietly produces a broken
|
|
122
|
+
image rather than failing: measured against Positron's
|
|
123
|
+
FigureCanvasPositron, savefig gave 2098 bytes with the title missing and
|
|
124
|
+
labels clipped, where Agg gave 12504 correct ones. savefig(backend="agg")
|
|
125
|
+
renders through Agg for this one call and leaves fig.canvas alone, so the
|
|
126
|
+
figure still belongs to the IDE and still shows in its plot pane.
|
|
127
|
+
|
|
128
|
+
Two things that look like fixes and are not. bbox_inches="tight" computes
|
|
129
|
+
a degenerate box on a figure the IDE has not drawn yet, and
|
|
130
|
+
canvas.draw() on such a canvas leaves it unrenderable. Rendering a
|
|
131
|
+
pickled copy loses data outright: a four-bar chart came back as one bar
|
|
132
|
+
on a default 0-to-1 axis.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
import matplotlib.pyplot as plt
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
nums = plt.get_fignums()
|
|
139
|
+
if not nums:
|
|
140
|
+
return None
|
|
141
|
+
target = fignum if fignum in nums else nums[-1]
|
|
142
|
+
try:
|
|
143
|
+
fig = plt.figure(target)
|
|
144
|
+
except Exception:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
data = b""
|
|
148
|
+
try:
|
|
149
|
+
buf = io.BytesIO()
|
|
150
|
+
fig.savefig(buf, format="png", backend="agg")
|
|
151
|
+
data = buf.getvalue()
|
|
152
|
+
except (TypeError, ValueError):
|
|
153
|
+
# The backend keyword arrived in matplotlib 3.4. Older versions get
|
|
154
|
+
# a plain savefig, which is correct wherever the canvas can raster.
|
|
155
|
+
try:
|
|
156
|
+
buf = io.BytesIO()
|
|
157
|
+
fig.savefig(buf, format="png")
|
|
158
|
+
data = buf.getvalue()
|
|
159
|
+
except Exception:
|
|
160
|
+
return None
|
|
161
|
+
except Exception:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
# A rendered figure is never this small. Refuse rather than hand back a
|
|
165
|
+
# blank picture that reads as a real result.
|
|
166
|
+
if len(data) < 1000:
|
|
167
|
+
return None
|
|
168
|
+
return base64.b64encode(data).decode()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _run(code: str, agent_id: Optional[str], want_plot: bool) -> Dict[str, Any]:
|
|
172
|
+
import sys as _sys
|
|
173
|
+
|
|
174
|
+
agent = agent_id or "agent"
|
|
175
|
+
if agent not in STATE.agents:
|
|
176
|
+
STATE.agents.append(agent)
|
|
177
|
+
|
|
178
|
+
if STATE.print_to_console:
|
|
179
|
+
try:
|
|
180
|
+
print("\n### LLM [%s] executing the following code ###\n%s\n"
|
|
181
|
+
"### End of LLM code ###\n" % (agent, code))
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
_log(code, agent)
|
|
186
|
+
|
|
187
|
+
with STATE.exec_lock:
|
|
188
|
+
try:
|
|
189
|
+
import matplotlib.pyplot as _plt
|
|
190
|
+
before = set(_plt.get_fignums())
|
|
191
|
+
except Exception:
|
|
192
|
+
before = set()
|
|
193
|
+
|
|
194
|
+
tee_out, tee_err = _Tee(_sys.stdout), _Tee(_sys.stderr)
|
|
195
|
+
real_out, real_err = _sys.stdout, _sys.stderr
|
|
196
|
+
_sys.stdout, _sys.stderr = tee_out, tee_err
|
|
197
|
+
error = None
|
|
198
|
+
try:
|
|
199
|
+
sh = _shell()
|
|
200
|
+
if sh is not None:
|
|
201
|
+
res = sh.run_cell(code, store_history=False, silent=False)
|
|
202
|
+
if getattr(res, "error_in_exec", None) is not None:
|
|
203
|
+
error = "".join(traceback.format_exception_only(
|
|
204
|
+
type(res.error_in_exec), res.error_in_exec)).strip()
|
|
205
|
+
elif getattr(res, "error_before_exec", None) is not None:
|
|
206
|
+
error = str(res.error_before_exec)
|
|
207
|
+
else:
|
|
208
|
+
exec(compile(code, "<clauder>", "exec"), STATE.namespace)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
error = "".join(traceback.format_exception_only(type(e), e)).strip()
|
|
211
|
+
finally:
|
|
212
|
+
_sys.stdout, _sys.stderr = real_out, real_err
|
|
213
|
+
|
|
214
|
+
output = tee_out.buf.getvalue() + tee_err.buf.getvalue()
|
|
215
|
+
if len(output) > MAX_OUTPUT_CHARS:
|
|
216
|
+
output = output[:MAX_OUTPUT_CHARS] + "\n... output truncated"
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
import matplotlib.pyplot as _plt
|
|
220
|
+
after = set(_plt.get_fignums())
|
|
221
|
+
except Exception:
|
|
222
|
+
after = set()
|
|
223
|
+
new_figs = after - before
|
|
224
|
+
drew = bool(new_figs) or (bool(after) and bool(before) and want_plot)
|
|
225
|
+
# The user may already have figures open, so identify ours rather than
|
|
226
|
+
# assuming the highest number is the one this call drew.
|
|
227
|
+
target_fig = max(new_figs) if new_figs else (max(after) if after else None)
|
|
228
|
+
|
|
229
|
+
STATE.execution_count += 1
|
|
230
|
+
resp: Dict[str, Any] = {"success": error is None, "output": output}
|
|
231
|
+
_log_output(output if error is None else (output + "\n" + error).strip())
|
|
232
|
+
if error is not None:
|
|
233
|
+
resp["error"] = error
|
|
234
|
+
|
|
235
|
+
if drew:
|
|
236
|
+
# want_plot is the with_plot tool asking outright, and always wins.
|
|
237
|
+
if want_plot or STATE.plot_auto:
|
|
238
|
+
png = _figure_png(target_fig)
|
|
239
|
+
if png:
|
|
240
|
+
resp["plot"] = {"data": png, "mime_type": "image/png"}
|
|
241
|
+
else:
|
|
242
|
+
resp["plot_available"] = True
|
|
243
|
+
resp["output"] = (resp["output"] + ("\n" if resp["output"] else "")
|
|
244
|
+
+ "[a plot was drawn; ask for it with the plot tool]")
|
|
245
|
+
return resp
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
249
|
+
protocol_version = "HTTP/1.1"
|
|
250
|
+
|
|
251
|
+
def log_message(self, *a): # keep the user's console clean
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
def _send(self, obj: Dict[str, Any], status: int = 200) -> None:
|
|
255
|
+
body = json.dumps(obj).encode()
|
|
256
|
+
self.send_response(status)
|
|
257
|
+
self.send_header("Content-Type", "application/json")
|
|
258
|
+
self.send_header("Content-Length", str(len(body)))
|
|
259
|
+
self.end_headers()
|
|
260
|
+
self.wfile.write(body)
|
|
261
|
+
|
|
262
|
+
def _authorised(self) -> bool:
|
|
263
|
+
# Only browsers set Origin, and the bridge never does, so this closes
|
|
264
|
+
# the drive-by webpage vector at no compatibility cost.
|
|
265
|
+
if self.headers.get("Origin"):
|
|
266
|
+
self._send({"error": "Cross-origin requests are not allowed"}, 403)
|
|
267
|
+
return False
|
|
268
|
+
if STATE.require_token:
|
|
269
|
+
if self.headers.get(TOKEN_HEADER) != STATE.token:
|
|
270
|
+
self._send({"error": "Missing or invalid session token"}, 401)
|
|
271
|
+
return False
|
|
272
|
+
return True
|
|
273
|
+
|
|
274
|
+
def do_GET(self):
|
|
275
|
+
if not self._authorised():
|
|
276
|
+
return
|
|
277
|
+
self._send({
|
|
278
|
+
"running": True,
|
|
279
|
+
"language": "python",
|
|
280
|
+
"session_name": STATE.session_name,
|
|
281
|
+
"port": STATE.port,
|
|
282
|
+
"execution_count": STATE.execution_count,
|
|
283
|
+
"connected_agents": list(STATE.agents),
|
|
284
|
+
"plot_auto": STATE.plot_auto,
|
|
285
|
+
"log_to_file": bool(STATE.log_path),
|
|
286
|
+
"log_file_path": STATE.log_path or "",
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
def do_POST(self):
|
|
290
|
+
if not self._authorised():
|
|
291
|
+
return
|
|
292
|
+
try:
|
|
293
|
+
n = int(self.headers.get("Content-Length") or 0)
|
|
294
|
+
body = json.loads(self.rfile.read(n) or b"{}")
|
|
295
|
+
except Exception:
|
|
296
|
+
self._send({"error": "Invalid JSON in request body"}, 400)
|
|
297
|
+
return
|
|
298
|
+
code = body.get("code")
|
|
299
|
+
if not isinstance(code, str):
|
|
300
|
+
self._send({"success": False, "error": "No code supplied"}, 400)
|
|
301
|
+
return
|
|
302
|
+
try:
|
|
303
|
+
self._send(_run(code, body.get("agent_id"),
|
|
304
|
+
bool(body.get("want_plot"))))
|
|
305
|
+
except Exception as e:
|
|
306
|
+
self._send({"success": False, "error": "clauder internal error: %s" % e}, 500)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def start(session_name: str = "default", port: int = 8790,
|
|
310
|
+
require_token: bool = False, log_file: Optional[str] = None,
|
|
311
|
+
plot_auto: bool = True, print_to_console: bool = True,
|
|
312
|
+
quiet: bool = False) -> Dict[str, Any]:
|
|
313
|
+
"""Expose this Python session to MCP agents.
|
|
314
|
+
|
|
315
|
+
Returns a dict describing the running server. Call stop() to shut it down.
|
|
316
|
+
"""
|
|
317
|
+
requested_port = int(port)
|
|
318
|
+
if STATE.httpd is not None:
|
|
319
|
+
if not quiet:
|
|
320
|
+
print("clauder: already running on port %s as '%s'."
|
|
321
|
+
% (STATE.port, STATE.session_name))
|
|
322
|
+
return {"running": True, "port": STATE.port,
|
|
323
|
+
"session_name": STATE.session_name}
|
|
324
|
+
|
|
325
|
+
STATE.session_name = session_name
|
|
326
|
+
STATE.require_token = require_token
|
|
327
|
+
STATE.plot_auto = plot_auto
|
|
328
|
+
STATE.print_to_console = print_to_console
|
|
329
|
+
STATE.token = secrets.token_hex(32)
|
|
330
|
+
STATE.execution_count = 0
|
|
331
|
+
STATE.agents = []
|
|
332
|
+
STATE.log_path = log_file if log_file is not None else os.path.join(
|
|
333
|
+
home_dir_safe(), "clauder_python_log.py")
|
|
334
|
+
|
|
335
|
+
# Walk forward to the next free port rather than failing. A second session,
|
|
336
|
+
# or a server the OS has not finished releasing, should not stop you
|
|
337
|
+
# starting; being told which port you got is enough.
|
|
338
|
+
httpd = None
|
|
339
|
+
first_error = None
|
|
340
|
+
for candidate in range(int(port), int(port) + 20):
|
|
341
|
+
try:
|
|
342
|
+
httpd = HTTPServer(("127.0.0.1", candidate), _Handler)
|
|
343
|
+
port = candidate
|
|
344
|
+
break
|
|
345
|
+
except OSError as e:
|
|
346
|
+
if first_error is None:
|
|
347
|
+
first_error = e
|
|
348
|
+
if httpd is None:
|
|
349
|
+
raise RuntimeError(
|
|
350
|
+
"No free port in %d-%d (%s). Pass port= explicitly."
|
|
351
|
+
% (int(port), int(port) + 19, first_error)
|
|
352
|
+
) from None
|
|
353
|
+
|
|
354
|
+
STATE.httpd = httpd
|
|
355
|
+
STATE.port = int(port)
|
|
356
|
+
try:
|
|
357
|
+
write_record(session_name, STATE.port, STATE.token,
|
|
358
|
+
plot_auto=plot_auto, tool_sets="all")
|
|
359
|
+
except Exception:
|
|
360
|
+
httpd.server_close()
|
|
361
|
+
STATE.httpd = None
|
|
362
|
+
STATE.port = None
|
|
363
|
+
raise
|
|
364
|
+
|
|
365
|
+
t = threading.Thread(target=httpd.serve_forever, daemon=True,
|
|
366
|
+
name="clauder-server")
|
|
367
|
+
t.start()
|
|
368
|
+
STATE.thread = t
|
|
369
|
+
|
|
370
|
+
if not quiet:
|
|
371
|
+
moved = " (requested port was busy)" if STATE.port != int(requested_port) else ""
|
|
372
|
+
print("clauder: listening on http://127.0.0.1:%d as session '%s'%s.\n"
|
|
373
|
+
"Agents can now reach this Python session. Call clauder.stop() "
|
|
374
|
+
"to end it." % (STATE.port, session_name, moved))
|
|
375
|
+
return {"running": True, "port": STATE.port, "session_name": session_name,
|
|
376
|
+
"language": "python", "token_required": require_token}
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def home_dir_safe() -> str:
|
|
380
|
+
from .discovery import home_dir
|
|
381
|
+
return home_dir()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def stop(quiet: bool = False) -> None:
|
|
385
|
+
"""Shut the server down and remove this session's discovery record."""
|
|
386
|
+
if STATE.httpd is None:
|
|
387
|
+
if not quiet:
|
|
388
|
+
print("clauder: not running.")
|
|
389
|
+
return
|
|
390
|
+
try:
|
|
391
|
+
STATE.httpd.shutdown()
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
try:
|
|
395
|
+
STATE.httpd.server_close()
|
|
396
|
+
except Exception:
|
|
397
|
+
pass
|
|
398
|
+
try:
|
|
399
|
+
remove_record(STATE.session_name)
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
STATE.httpd = None
|
|
403
|
+
STATE.thread = None
|
|
404
|
+
STATE.port = None
|
|
405
|
+
if not quiet:
|
|
406
|
+
print("clauder: stopped.")
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def status() -> Dict[str, Any]:
|
|
410
|
+
return {
|
|
411
|
+
"running": STATE.httpd is not None,
|
|
412
|
+
"session_name": STATE.session_name,
|
|
413
|
+
"port": STATE.port,
|
|
414
|
+
"language": "python",
|
|
415
|
+
"execution_count": STATE.execution_count,
|
|
416
|
+
"connected_agents": list(STATE.agents),
|
|
417
|
+
"plot_auto": STATE.plot_auto,
|
|
418
|
+
"log_file": STATE.log_path,
|
|
419
|
+
"token_required": STATE.require_token,
|
|
420
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Tests for the Python session server.
|
|
2
|
+
|
|
3
|
+
Each test runs against a private discovery directory, never the user's real
|
|
4
|
+
~/.claude_r_sessions, so a test run cannot disturb a live research session.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
17
|
+
|
|
18
|
+
from clauder import server as srv # noqa: E402
|
|
19
|
+
from clauder import discovery as disc # noqa: E402
|
|
20
|
+
|
|
21
|
+
PORT = 8894
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Base(unittest.TestCase):
|
|
25
|
+
def setUp(self):
|
|
26
|
+
self.dir = tempfile.mkdtemp()
|
|
27
|
+
self._real_home = disc.home_dir
|
|
28
|
+
disc.home_dir = lambda: self.dir
|
|
29
|
+
self.log = os.path.join(self.dir, "log.py")
|
|
30
|
+
|
|
31
|
+
def tearDown(self):
|
|
32
|
+
srv.stop(quiet=True)
|
|
33
|
+
disc.home_dir = self._real_home
|
|
34
|
+
|
|
35
|
+
def start(self, **kw):
|
|
36
|
+
kw.setdefault("session_name", "t")
|
|
37
|
+
kw.setdefault("port", PORT)
|
|
38
|
+
kw.setdefault("quiet", True)
|
|
39
|
+
kw.setdefault("print_to_console", False)
|
|
40
|
+
kw.setdefault("log_file", self.log)
|
|
41
|
+
return srv.start(**kw)
|
|
42
|
+
|
|
43
|
+
def post(self, code, want_plot=False, token=None, origin=None, agent="tester"):
|
|
44
|
+
body = {"code": code, "agent_id": agent}
|
|
45
|
+
if want_plot:
|
|
46
|
+
body["want_plot"] = True
|
|
47
|
+
h = {"Content-Type": "application/json"}
|
|
48
|
+
if token:
|
|
49
|
+
h[disc.TOKEN_HEADER] = token
|
|
50
|
+
if origin:
|
|
51
|
+
h["Origin"] = origin
|
|
52
|
+
r = urllib.request.Request("http://127.0.0.1:%d" % PORT,
|
|
53
|
+
json.dumps(body).encode(), h)
|
|
54
|
+
return json.load(urllib.request.urlopen(r, timeout=25))
|
|
55
|
+
|
|
56
|
+
def record(self):
|
|
57
|
+
with open(disc.discovery_path("t")) as fh:
|
|
58
|
+
return json.load(fh)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DiscoveryTests(Base):
|
|
62
|
+
def test_record_declares_python_and_matches_r_shape(self):
|
|
63
|
+
self.start()
|
|
64
|
+
rec = self.record()
|
|
65
|
+
# The bridge reads these keys for every session regardless of language.
|
|
66
|
+
for k in ("session_name", "port", "pid", "token", "started_at",
|
|
67
|
+
"plot_auto", "tool_sets", "language"):
|
|
68
|
+
self.assertIn(k, rec)
|
|
69
|
+
self.assertEqual(rec["language"], "python")
|
|
70
|
+
self.assertEqual(rec["port"], PORT)
|
|
71
|
+
self.assertEqual(rec["pid"], os.getpid())
|
|
72
|
+
|
|
73
|
+
def test_record_is_private(self):
|
|
74
|
+
self.start()
|
|
75
|
+
mode = os.stat(disc.discovery_path("t")).st_mode & 0o777
|
|
76
|
+
self.assertEqual(mode, 0o600)
|
|
77
|
+
|
|
78
|
+
def test_stop_removes_only_our_record(self):
|
|
79
|
+
self.start()
|
|
80
|
+
p = disc.discovery_path("t")
|
|
81
|
+
self.assertTrue(os.path.exists(p))
|
|
82
|
+
srv.stop(quiet=True)
|
|
83
|
+
self.assertFalse(os.path.exists(p))
|
|
84
|
+
|
|
85
|
+
def test_refuses_name_held_by_another_live_process(self):
|
|
86
|
+
os.makedirs(disc.discovery_dir(), mode=0o700, exist_ok=True)
|
|
87
|
+
with open(disc.discovery_path("t"), "w") as fh:
|
|
88
|
+
json.dump({"session_name": "t", "port": 1, "pid": 1,
|
|
89
|
+
"token": "x", "started_at": "s"}, fh)
|
|
90
|
+
with self.assertRaises(RuntimeError):
|
|
91
|
+
self.start()
|
|
92
|
+
|
|
93
|
+
def test_pid_probe_is_tristate(self):
|
|
94
|
+
self.assertIs(disc.pid_is_alive(os.getpid()), True)
|
|
95
|
+
self.assertIs(disc.pid_is_alive(999999), False)
|
|
96
|
+
self.assertIsNone(disc.pid_is_alive("nonsense"))
|
|
97
|
+
|
|
98
|
+
def test_rejects_unsafe_session_names(self):
|
|
99
|
+
for bad in ("..", "a/b", "a\\b", "a:b", ""):
|
|
100
|
+
with self.assertRaises(ValueError):
|
|
101
|
+
disc.discovery_path(bad)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ExecutionTests(Base):
|
|
105
|
+
def test_executes_and_persists_namespace(self):
|
|
106
|
+
self.start()
|
|
107
|
+
self.assertTrue(self.post("zz = 6 * 7")["success"])
|
|
108
|
+
r = self.post("print('zz is', zz)")
|
|
109
|
+
self.assertIn("zz is 42", r["output"])
|
|
110
|
+
|
|
111
|
+
def test_reports_errors_without_dying(self):
|
|
112
|
+
self.start()
|
|
113
|
+
r = self.post("1/0")
|
|
114
|
+
self.assertFalse(r["success"])
|
|
115
|
+
self.assertIn("ZeroDivisionError", r["error"])
|
|
116
|
+
self.assertTrue(self.post("1+1")["success"]) # still serving
|
|
117
|
+
|
|
118
|
+
def test_captures_stdout(self):
|
|
119
|
+
self.start()
|
|
120
|
+
r = self.post("for i in range(3): print('row', i)")
|
|
121
|
+
self.assertIn("row 0", r["output"])
|
|
122
|
+
self.assertIn("row 2", r["output"])
|
|
123
|
+
|
|
124
|
+
def test_logs_code_and_output_with_attribution(self):
|
|
125
|
+
self.start()
|
|
126
|
+
self.post("print('logged')", agent="agent-7")
|
|
127
|
+
text = open(self.log).read()
|
|
128
|
+
self.assertIn("# Code executed by agent-7:", text) # frozen R marker
|
|
129
|
+
self.assertIn("#> logged", text) # output marker
|
|
130
|
+
# The code must appear exactly once, not once per log call.
|
|
131
|
+
self.assertEqual(text.count("print('logged')"), 1)
|
|
132
|
+
|
|
133
|
+
def test_output_is_truncated(self):
|
|
134
|
+
# Lower the cap rather than printing a huge string: the tee echoes to
|
|
135
|
+
# the real console by design, and a 200k flood makes test output
|
|
136
|
+
# unreadable.
|
|
137
|
+
self.start()
|
|
138
|
+
original = srv.MAX_OUTPUT_CHARS
|
|
139
|
+
srv.MAX_OUTPUT_CHARS = 100
|
|
140
|
+
try:
|
|
141
|
+
r = self.post("print('x' * 500)")
|
|
142
|
+
self.assertLessEqual(len(r["output"]), 100 + 40)
|
|
143
|
+
self.assertIn("truncated", r["output"])
|
|
144
|
+
finally:
|
|
145
|
+
srv.MAX_OUTPUT_CHARS = original
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SecurityTests(Base):
|
|
149
|
+
def test_origin_header_is_refused(self):
|
|
150
|
+
self.start()
|
|
151
|
+
with self.assertRaises(urllib.error.HTTPError) as e:
|
|
152
|
+
self.post("1+1", origin="http://evil.test")
|
|
153
|
+
self.assertEqual(e.exception.code, 403)
|
|
154
|
+
|
|
155
|
+
def test_token_enforced_when_required(self):
|
|
156
|
+
self.start(require_token=True)
|
|
157
|
+
tok = self.record()["token"]
|
|
158
|
+
with self.assertRaises(urllib.error.HTTPError) as e:
|
|
159
|
+
self.post("1+1")
|
|
160
|
+
self.assertEqual(e.exception.code, 401)
|
|
161
|
+
self.assertTrue(self.post("1+1", token=tok)["success"])
|
|
162
|
+
|
|
163
|
+
def test_token_absent_by_default(self):
|
|
164
|
+
self.start()
|
|
165
|
+
self.assertTrue(self.post("1+1")["success"])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class PlotTests(Base):
|
|
169
|
+
def setUp(self):
|
|
170
|
+
super().setUp()
|
|
171
|
+
try:
|
|
172
|
+
import matplotlib
|
|
173
|
+
matplotlib.use("Agg")
|
|
174
|
+
except ImportError:
|
|
175
|
+
self.skipTest("matplotlib not installed")
|
|
176
|
+
|
|
177
|
+
def draw(self, **kw):
|
|
178
|
+
return self.post("import matplotlib.pyplot as plt\n"
|
|
179
|
+
"plt.figure(); plt.plot([1, 4, 9])", **kw)
|
|
180
|
+
|
|
181
|
+
def test_returns_png_when_a_figure_is_drawn(self):
|
|
182
|
+
self.start()
|
|
183
|
+
r = self.draw()
|
|
184
|
+
self.assertIn("plot", r)
|
|
185
|
+
self.assertEqual(r["plot"]["mime_type"], "image/png")
|
|
186
|
+
self.assertTrue(base64.b64decode(r["plot"]["data"]).startswith(b"\x89PNG"))
|
|
187
|
+
|
|
188
|
+
def test_no_plot_when_nothing_drawn(self):
|
|
189
|
+
self.start()
|
|
190
|
+
self.assertNotIn("plot", self.post("q = 1"))
|
|
191
|
+
|
|
192
|
+
def test_plot_auto_off_withholds_image_but_says_so(self):
|
|
193
|
+
self.start(plot_auto=False)
|
|
194
|
+
r = self.draw()
|
|
195
|
+
self.assertNotIn("plot", r)
|
|
196
|
+
self.assertTrue(r["plot_available"])
|
|
197
|
+
self.assertIn("plot was drawn", r["output"])
|
|
198
|
+
|
|
199
|
+
def test_want_plot_overrides_plot_auto(self):
|
|
200
|
+
self.start(plot_auto=False)
|
|
201
|
+
self.assertIn("plot", self.draw(want_plot=True))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class StatusTests(Base):
|
|
205
|
+
def test_get_reports_language_and_agents(self):
|
|
206
|
+
self.start()
|
|
207
|
+
self.post("1+1", agent="agent-a")
|
|
208
|
+
r = urllib.request.Request("http://127.0.0.1:%d" % PORT)
|
|
209
|
+
r.get_method = lambda: "GET"
|
|
210
|
+
info = json.load(urllib.request.urlopen(r, timeout=10))
|
|
211
|
+
self.assertEqual(info["language"], "python")
|
|
212
|
+
self.assertTrue(info["running"])
|
|
213
|
+
self.assertIn("agent-a", info["connected_agents"])
|
|
214
|
+
|
|
215
|
+
def test_status_helper(self):
|
|
216
|
+
self.start()
|
|
217
|
+
self.assertTrue(srv.status()["running"])
|
|
218
|
+
srv.stop(quiet=True)
|
|
219
|
+
self.assertFalse(srv.status()["running"])
|
|
220
|
+
|
|
221
|
+
def test_double_start_is_a_no_op(self):
|
|
222
|
+
a = self.start()
|
|
223
|
+
b = self.start()
|
|
224
|
+
self.assertEqual(a["port"], b["port"])
|
|
225
|
+
|
|
226
|
+
def test_busy_port_moves_to_the_next_free_one(self):
|
|
227
|
+
# Occupy the requested port from outside. Calling start() twice would
|
|
228
|
+
# hit the already-running guard and never reach the bind.
|
|
229
|
+
import socket
|
|
230
|
+
sock = socket.socket()
|
|
231
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
232
|
+
sock.bind(("127.0.0.1", PORT + 1))
|
|
233
|
+
sock.listen(1)
|
|
234
|
+
try:
|
|
235
|
+
info = srv.start(session_name="t", port=PORT + 1, quiet=True,
|
|
236
|
+
log_file=self.log)
|
|
237
|
+
self.assertNotEqual(info["port"], PORT + 1)
|
|
238
|
+
self.assertGreater(info["port"], PORT + 1)
|
|
239
|
+
# The record must carry the port actually bound, not the one asked
|
|
240
|
+
# for, or the bridge would post to the wrong place.
|
|
241
|
+
self.assertEqual(self.record()["port"], info["port"])
|
|
242
|
+
finally:
|
|
243
|
+
sock.close()
|
|
244
|
+
|
|
245
|
+
def test_gives_up_when_the_whole_range_is_busy(self):
|
|
246
|
+
import socket
|
|
247
|
+
base = PORT + 40
|
|
248
|
+
socks = []
|
|
249
|
+
try:
|
|
250
|
+
for i in range(20):
|
|
251
|
+
s_ = socket.socket()
|
|
252
|
+
s_.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
253
|
+
s_.bind(("127.0.0.1", base + i))
|
|
254
|
+
s_.listen(1)
|
|
255
|
+
socks.append(s_)
|
|
256
|
+
with self.assertRaises(RuntimeError) as e:
|
|
257
|
+
srv.start(session_name="t", port=base, quiet=True,
|
|
258
|
+
log_file=self.log)
|
|
259
|
+
self.assertIn("No free port", str(e.exception))
|
|
260
|
+
finally:
|
|
261
|
+
for s_ in socks:
|
|
262
|
+
s_.close()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
unittest.main(verbosity=2)
|