coding-agent-wrapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caw/__init__.py +88 -0
- caw/agent.py +578 -0
- caw/auth/README.md +118 -0
- caw/auth/__init__.py +23 -0
- caw/auth/cli.py +68 -0
- caw/auth/collector.py +324 -0
- caw/auth/linker.py +174 -0
- caw/auth/manifest.py +77 -0
- caw/auth/providers.py +433 -0
- caw/auth/status.py +241 -0
- caw/cli.py +50 -0
- caw/display.py +223 -0
- caw/faststats.py +298 -0
- caw/mcp.py +602 -0
- caw/models.py +385 -0
- caw/pricing.json +15 -0
- caw/pricing.py +33 -0
- caw/provider.py +135 -0
- caw/providers/__init__.py +0 -0
- caw/providers/claude_code.py +648 -0
- caw/providers/codex.py +564 -0
- caw/py.typed +0 -0
- caw/storage.py +184 -0
- caw/toolkit.py +198 -0
- caw/viewer/__init__.py +149 -0
- caw/viewer/static/index.html +847 -0
- coding_agent_wrapper-0.1.0.dist-info/METADATA +213 -0
- coding_agent_wrapper-0.1.0.dist-info/RECORD +31 -0
- coding_agent_wrapper-0.1.0.dist-info/WHEEL +4 -0
- coding_agent_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- coding_agent_wrapper-0.1.0.dist-info/licenses/LICENSE +202 -0
caw/storage.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Session data persistence — writes raw session data to disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fcntl
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from caw.models import TextBlock, ThinkingBlock, ToolUse, Trajectory, Turn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JsonlWriter:
|
|
14
|
+
"""Append-only JSONL writer with file locking for concurrent safety.
|
|
15
|
+
|
|
16
|
+
When *subagent* is set, every entry is tagged with ``"subagent": name``
|
|
17
|
+
so readers can distinguish parent vs. subagent events.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: str | Path, *, subagent: str | None = None) -> None:
|
|
21
|
+
self._path = Path(path)
|
|
22
|
+
self._subagent = subagent
|
|
23
|
+
|
|
24
|
+
def append(self, entry: dict[str, Any]) -> None:
|
|
25
|
+
"""Append a single JSON object as one line (file-locked)."""
|
|
26
|
+
if self._subagent:
|
|
27
|
+
entry = {**entry, "subagent": self._subagent}
|
|
28
|
+
with open(self._path, "a", encoding="utf-8") as f:
|
|
29
|
+
fcntl.flock(f, fcntl.LOCK_EX)
|
|
30
|
+
f.write(json.dumps(entry) + "\n")
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
# High-level helpers
|
|
34
|
+
# ------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def write_metadata(self, trajectory: Trajectory) -> None:
|
|
37
|
+
"""Write a metadata entry (typically once at session start)."""
|
|
38
|
+
self.append(
|
|
39
|
+
{
|
|
40
|
+
"type": "metadata",
|
|
41
|
+
"session_id": trajectory.session_id,
|
|
42
|
+
"created_at": trajectory.created_at,
|
|
43
|
+
"agent": trajectory.agent,
|
|
44
|
+
"model": trajectory.model,
|
|
45
|
+
"system_prompt": trajectory.system_prompt,
|
|
46
|
+
"reasoning": trajectory.reasoning,
|
|
47
|
+
"mcp_servers": [
|
|
48
|
+
{"name": s.name, "command": s.command, "args": s.args, "env": s.env, "url": s.url}
|
|
49
|
+
for s in trajectory.mcp_servers
|
|
50
|
+
],
|
|
51
|
+
"metadata": trajectory.metadata,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def write_turn_events(self, turn: Turn, turn_index: int) -> None:
|
|
56
|
+
"""Write per-event JSONL lines for a completed turn."""
|
|
57
|
+
# User message
|
|
58
|
+
self.append({"type": "user", "message": turn.input, "turn_index": turn_index})
|
|
59
|
+
|
|
60
|
+
# Content blocks — mirrors Display event order
|
|
61
|
+
for block in turn.output:
|
|
62
|
+
if isinstance(block, ThinkingBlock):
|
|
63
|
+
self.append(
|
|
64
|
+
{
|
|
65
|
+
"type": "thinking",
|
|
66
|
+
"text": block.text,
|
|
67
|
+
"turn_index": turn_index,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
elif isinstance(block, TextBlock):
|
|
71
|
+
self.append(
|
|
72
|
+
{
|
|
73
|
+
"type": "text",
|
|
74
|
+
"text": block.text,
|
|
75
|
+
"turn_index": turn_index,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
elif isinstance(block, ToolUse):
|
|
79
|
+
self.append(
|
|
80
|
+
{
|
|
81
|
+
"type": "tool_call",
|
|
82
|
+
"id": block.id,
|
|
83
|
+
"name": block.name,
|
|
84
|
+
"arguments": block.arguments,
|
|
85
|
+
"turn_index": turn_index,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
result_entry: dict[str, Any] = {
|
|
89
|
+
"type": "tool_result",
|
|
90
|
+
"id": block.id,
|
|
91
|
+
"name": block.name,
|
|
92
|
+
"output": block.output,
|
|
93
|
+
"is_error": block.is_error,
|
|
94
|
+
"turn_index": turn_index,
|
|
95
|
+
}
|
|
96
|
+
if block.subagent_trajectory:
|
|
97
|
+
result_entry["subagent_trajectory"] = block.subagent_trajectory.to_dict()
|
|
98
|
+
self.append(result_entry)
|
|
99
|
+
|
|
100
|
+
# Turn-end stats
|
|
101
|
+
self.append(
|
|
102
|
+
{
|
|
103
|
+
"type": "turn_end",
|
|
104
|
+
"turn_index": turn_index,
|
|
105
|
+
"usage": turn.usage.to_dict(),
|
|
106
|
+
"duration_ms": turn.duration_ms,
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class SessionStore:
|
|
112
|
+
"""Persists session data to a directory on disk.
|
|
113
|
+
|
|
114
|
+
Layout::
|
|
115
|
+
|
|
116
|
+
<data_dir>/sessions/<session_id>/
|
|
117
|
+
traj.jsonl # incremental append-only event log
|
|
118
|
+
trajectory.json # full trajectory, overwritten after each turn
|
|
119
|
+
turns/
|
|
120
|
+
000_input.txt
|
|
121
|
+
000_raw_output.jsonl
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def __init__(self, data_dir: str | Path, session_id: str) -> None:
|
|
125
|
+
self._session_dir = Path(data_dir) / "sessions" / session_id
|
|
126
|
+
self._turns_dir = self._session_dir / "turns"
|
|
127
|
+
self._turns_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
self._turn_counter = 0
|
|
129
|
+
self._jsonl = JsonlWriter(self._session_dir / "traj.jsonl")
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def session_dir(self) -> Path:
|
|
133
|
+
"""Path to this session's directory."""
|
|
134
|
+
return self._session_dir
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def jsonl_path(self) -> Path:
|
|
138
|
+
"""Path to the traj.jsonl file."""
|
|
139
|
+
return self._jsonl._path
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# JSONL delegation
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def write_metadata(self, trajectory: Trajectory) -> None:
|
|
146
|
+
"""Append a metadata entry to traj.jsonl (called once at session start)."""
|
|
147
|
+
self._jsonl.write_metadata(trajectory)
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Trajectory
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _save_trajectory(self, trajectory: Trajectory) -> None:
|
|
154
|
+
"""Overwrite trajectory.json with the full trajectory."""
|
|
155
|
+
path = self._session_dir / "trajectory.json"
|
|
156
|
+
path.write_text(json.dumps(trajectory.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
157
|
+
|
|
158
|
+
def finalize(self, trajectory: Trajectory) -> None:
|
|
159
|
+
"""Write trajectory.json with the complete session record."""
|
|
160
|
+
self._save_trajectory(trajectory)
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Turn files
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def append_turn(self, turn: Turn, trajectory: Trajectory, raw_output: str | None = None) -> None:
|
|
167
|
+
"""Record a completed turn: event JSONL lines, trajectory snapshot, and raw files."""
|
|
168
|
+
prefix = f"{self._turn_counter:03d}"
|
|
169
|
+
|
|
170
|
+
# Raw turn files
|
|
171
|
+
input_path = self._turns_dir / f"{prefix}_input.txt"
|
|
172
|
+
input_path.write_text(turn.input, encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
if raw_output is not None:
|
|
175
|
+
output_path = self._turns_dir / f"{prefix}_raw_output.jsonl"
|
|
176
|
+
output_path.write_text(raw_output, encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
# Per-event JSONL lines
|
|
179
|
+
self._jsonl.write_turn_events(turn, self._turn_counter)
|
|
180
|
+
|
|
181
|
+
# Full trajectory snapshot
|
|
182
|
+
self._save_trajectory(trajectory)
|
|
183
|
+
|
|
184
|
+
self._turn_counter += 1
|
caw/toolkit.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""ToolKit base class and @tool decorator for declarative MCP tool servers.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from caw import Agent, ToolKit, tool
|
|
6
|
+
|
|
7
|
+
class UserDB(ToolKit, server_name="user_db", display_name="User Database"):
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self.users = ["Alice", "Bob"]
|
|
10
|
+
|
|
11
|
+
@tool(description="List all users")
|
|
12
|
+
async def list_users(self) -> str:
|
|
13
|
+
return ", ".join(self.users)
|
|
14
|
+
|
|
15
|
+
db = UserDB()
|
|
16
|
+
agent = Agent(system_prompt="You have a user DB.", tool_servers=[db])
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import inspect
|
|
23
|
+
import threading
|
|
24
|
+
import uuid as uuid_mod
|
|
25
|
+
from typing import Any, ClassVar
|
|
26
|
+
|
|
27
|
+
from caw.mcp import (
|
|
28
|
+
Context,
|
|
29
|
+
MCPServerHandle,
|
|
30
|
+
create_mcp_http_server_bundle,
|
|
31
|
+
get_state_from_context,
|
|
32
|
+
register_tool,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# -- @tool decorator ----------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def tool(
|
|
40
|
+
name: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
description: str | None = None,
|
|
43
|
+
title: str | None = None,
|
|
44
|
+
annotations: Any | None = None,
|
|
45
|
+
structured_output: bool | None = None,
|
|
46
|
+
):
|
|
47
|
+
"""Mark a method as an MCP tool. Does NOT modify the function itself."""
|
|
48
|
+
|
|
49
|
+
def decorator(method):
|
|
50
|
+
method._toolkit_tool_info = {
|
|
51
|
+
"name": name,
|
|
52
|
+
"description": description,
|
|
53
|
+
"title": title,
|
|
54
|
+
"annotations": annotations,
|
|
55
|
+
"structured_output": structured_output,
|
|
56
|
+
}
|
|
57
|
+
return method
|
|
58
|
+
|
|
59
|
+
return decorator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# -- ToolKit base class -------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ToolKit:
|
|
66
|
+
"""Base class for declarative MCP tool servers.
|
|
67
|
+
|
|
68
|
+
Subclass, decorate methods with ``@tool``, call ``as_server()``
|
|
69
|
+
to get an :class:`MCPServerHandle`.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_server_name: ClassVar[str] = ""
|
|
73
|
+
_display_name: ClassVar[str] = ""
|
|
74
|
+
_thread_safe: ClassVar[bool] = False
|
|
75
|
+
|
|
76
|
+
def __init_subclass__(
|
|
77
|
+
cls,
|
|
78
|
+
server_name: str = "",
|
|
79
|
+
display_name: str = "",
|
|
80
|
+
thread_safe: bool = False,
|
|
81
|
+
**kwargs: Any,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init_subclass__(**kwargs)
|
|
84
|
+
cls._server_name = server_name or cls._server_name
|
|
85
|
+
cls._display_name = display_name or cls._display_name
|
|
86
|
+
if thread_safe:
|
|
87
|
+
cls._thread_safe = True
|
|
88
|
+
|
|
89
|
+
def as_server(self, server_id: str | None = None) -> MCPServerHandle:
|
|
90
|
+
"""Build and return an :class:`MCPServerHandle` with all ``@tool`` methods registered."""
|
|
91
|
+
cls = type(self)
|
|
92
|
+
if cls._thread_safe and not hasattr(self, "_toolkit_lock"):
|
|
93
|
+
self._toolkit_lock = threading.Lock()
|
|
94
|
+
sid = server_id or f"{cls._server_name or cls.__name__}_{uuid_mod.uuid4().hex[:6]}"
|
|
95
|
+
|
|
96
|
+
handle = create_mcp_http_server_bundle(
|
|
97
|
+
sid,
|
|
98
|
+
display_name=cls._display_name or cls._server_name or cls.__name__,
|
|
99
|
+
state_instance=self,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
for attr_name in dir(cls):
|
|
103
|
+
attr = getattr(cls, attr_name, None)
|
|
104
|
+
if attr is None:
|
|
105
|
+
continue
|
|
106
|
+
info = getattr(attr, "_toolkit_tool_info", None)
|
|
107
|
+
if info is None:
|
|
108
|
+
continue
|
|
109
|
+
wrapper = _make_tool_func(attr, info)
|
|
110
|
+
register_tool(handle.server, wrapper)
|
|
111
|
+
|
|
112
|
+
return handle
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# -- Wrapper generator --------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _make_tool_func(method, info: dict[str, Any]):
|
|
119
|
+
"""Create an MCP-compatible free function from a ToolKit method.
|
|
120
|
+
|
|
121
|
+
The generated wrapper:
|
|
122
|
+
- Drops ``self`` from the signature.
|
|
123
|
+
- Appends ``ctx: Context`` (or keeps it if the user already declared it).
|
|
124
|
+
- At call time, retrieves the ToolKit instance from context and delegates.
|
|
125
|
+
"""
|
|
126
|
+
sig = inspect.signature(method)
|
|
127
|
+
params = list(sig.parameters.values())
|
|
128
|
+
|
|
129
|
+
# Remove 'self'
|
|
130
|
+
if params and params[0].name == "self":
|
|
131
|
+
params = params[1:]
|
|
132
|
+
|
|
133
|
+
# Check if the user declared a 'ctx' parameter
|
|
134
|
+
user_has_ctx = any(p.name == "ctx" for p in params)
|
|
135
|
+
|
|
136
|
+
# Build new parameter list: all user params (minus ctx if present) + ctx at end
|
|
137
|
+
new_params = [p for p in params if p.name != "ctx"]
|
|
138
|
+
ctx_param = inspect.Parameter(
|
|
139
|
+
"ctx",
|
|
140
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
141
|
+
annotation=Context,
|
|
142
|
+
)
|
|
143
|
+
new_params.append(ctx_param)
|
|
144
|
+
|
|
145
|
+
new_sig = sig.replace(parameters=new_params)
|
|
146
|
+
|
|
147
|
+
is_async = asyncio.iscoroutinefunction(method)
|
|
148
|
+
|
|
149
|
+
if is_async:
|
|
150
|
+
|
|
151
|
+
async def wrapper(**kwargs):
|
|
152
|
+
ctx = kwargs.pop("ctx")
|
|
153
|
+
self_instance = get_state_from_context(ctx)
|
|
154
|
+
if user_has_ctx:
|
|
155
|
+
kwargs["ctx"] = ctx
|
|
156
|
+
lock = getattr(self_instance, "_toolkit_lock", None)
|
|
157
|
+
if lock is not None:
|
|
158
|
+
with lock:
|
|
159
|
+
return await method(self_instance, **kwargs)
|
|
160
|
+
return await method(self_instance, **kwargs)
|
|
161
|
+
|
|
162
|
+
else:
|
|
163
|
+
|
|
164
|
+
async def wrapper(**kwargs):
|
|
165
|
+
ctx = kwargs.pop("ctx")
|
|
166
|
+
self_instance = get_state_from_context(ctx)
|
|
167
|
+
if user_has_ctx:
|
|
168
|
+
kwargs["ctx"] = ctx
|
|
169
|
+
lock = getattr(self_instance, "_toolkit_lock", None)
|
|
170
|
+
if lock is not None:
|
|
171
|
+
with lock:
|
|
172
|
+
return method(self_instance, **kwargs)
|
|
173
|
+
return method(self_instance, **kwargs)
|
|
174
|
+
|
|
175
|
+
tool_name = info.get("name") or method.__name__
|
|
176
|
+
wrapper.__name__ = tool_name
|
|
177
|
+
wrapper.__qualname__ = tool_name
|
|
178
|
+
wrapper.__doc__ = info.get("description") or method.__doc__ or ""
|
|
179
|
+
wrapper.__signature__ = new_sig
|
|
180
|
+
# Set __annotations__ so typing.get_type_hints() can find the Context
|
|
181
|
+
# parameter — FastMCP uses get_type_hints (not __signature__) to detect
|
|
182
|
+
# which params to strip from the input schema and inject at call time.
|
|
183
|
+
wrapper.__annotations__ = {
|
|
184
|
+
p.name: p.annotation for p in new_sig.parameters.values() if p.annotation is not inspect.Parameter.empty
|
|
185
|
+
}
|
|
186
|
+
if new_sig.return_annotation is not inspect.Signature.empty:
|
|
187
|
+
wrapper.__annotations__["return"] = new_sig.return_annotation
|
|
188
|
+
|
|
189
|
+
# Attach _mcp_tool_info for register_tool()
|
|
190
|
+
wrapper._mcp_tool_info = {
|
|
191
|
+
"name": tool_name,
|
|
192
|
+
"title": info.get("title"),
|
|
193
|
+
"description": info.get("description") or method.__doc__ or "",
|
|
194
|
+
"annotations": info.get("annotations"),
|
|
195
|
+
"structured_output": info.get("structured_output"),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return wrapper
|
caw/viewer/__init__.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Trajectory viewer web interface.
|
|
2
|
+
|
|
3
|
+
Provides a simple HTTP server that serves a self-contained trajectory viewer.
|
|
4
|
+
The viewer loads trajectory JSON files by absolute path.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from caw.viewer import start_viewer_server
|
|
9
|
+
|
|
10
|
+
server = start_viewer_server() # auto host/port
|
|
11
|
+
server = start_viewer_server(port=8080) # fixed port
|
|
12
|
+
print(server.url) # http://localhost:8080
|
|
13
|
+
server.check_status() # True / False
|
|
14
|
+
server.stop()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import socket
|
|
21
|
+
import threading
|
|
22
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from socketserver import ThreadingMixIn
|
|
25
|
+
from urllib.parse import parse_qs, urlparse
|
|
26
|
+
|
|
27
|
+
__all__ = ["ViewerServer", "start_viewer_server"]
|
|
28
|
+
|
|
29
|
+
STATIC_DIR = Path(__file__).parent / "static"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _find_free_port() -> int:
|
|
33
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
34
|
+
s.bind(("", 0))
|
|
35
|
+
return s.getsockname()[1]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
39
|
+
daemon_threads = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ViewerServer:
|
|
43
|
+
"""Handle for a running trajectory viewer server."""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
httpd: HTTPServer,
|
|
48
|
+
thread: threading.Thread,
|
|
49
|
+
host: str,
|
|
50
|
+
port: int,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._httpd = httpd
|
|
53
|
+
self._thread = thread
|
|
54
|
+
self.host = host
|
|
55
|
+
self.port = port
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def url(self) -> str:
|
|
59
|
+
return f"http://{self.host}:{self.port}"
|
|
60
|
+
|
|
61
|
+
def check_status(self) -> bool:
|
|
62
|
+
"""Return True if the server is running."""
|
|
63
|
+
return self._thread.is_alive()
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
"""Shut down the server."""
|
|
67
|
+
self._httpd.shutdown()
|
|
68
|
+
self._thread.join(timeout=5)
|
|
69
|
+
|
|
70
|
+
def __repr__(self) -> str:
|
|
71
|
+
status = "running" if self.check_status() else "stopped"
|
|
72
|
+
return f"ViewerServer({self.url}, {status})"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
76
|
+
def log_message(self, format, *args): # noqa: A002
|
|
77
|
+
pass # suppress request logging
|
|
78
|
+
|
|
79
|
+
def do_GET(self): # noqa: N802
|
|
80
|
+
parsed = urlparse(self.path)
|
|
81
|
+
path = parsed.path
|
|
82
|
+
|
|
83
|
+
if path == "/" or path == "/index.html":
|
|
84
|
+
self._serve_file(STATIC_DIR / "index.html", "text/html")
|
|
85
|
+
elif path == "/api/trajectory":
|
|
86
|
+
self._handle_trajectory(parse_qs(parsed.query))
|
|
87
|
+
else:
|
|
88
|
+
self.send_error(404)
|
|
89
|
+
|
|
90
|
+
def _send_json(self, data, status: int = 200):
|
|
91
|
+
body = json.dumps(data).encode()
|
|
92
|
+
self.send_response(status)
|
|
93
|
+
self.send_header("Content-Type", "application/json")
|
|
94
|
+
self.send_header("Content-Length", str(len(body)))
|
|
95
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
96
|
+
self.end_headers()
|
|
97
|
+
self.wfile.write(body)
|
|
98
|
+
|
|
99
|
+
def _serve_file(self, filepath: Path, content_type: str):
|
|
100
|
+
if not filepath.is_file():
|
|
101
|
+
self.send_error(404)
|
|
102
|
+
return
|
|
103
|
+
content = filepath.read_bytes()
|
|
104
|
+
self.send_response(200)
|
|
105
|
+
self.send_header("Content-Type", f"{content_type}; charset=utf-8")
|
|
106
|
+
self.send_header("Content-Length", str(len(content)))
|
|
107
|
+
self.end_headers()
|
|
108
|
+
self.wfile.write(content)
|
|
109
|
+
|
|
110
|
+
def _handle_trajectory(self, params):
|
|
111
|
+
paths = params.get("path", [])
|
|
112
|
+
if not paths:
|
|
113
|
+
self._send_json({"detail": "Missing 'path' parameter"}, 400)
|
|
114
|
+
return
|
|
115
|
+
filepath = Path(paths[0]).resolve()
|
|
116
|
+
if not filepath.is_file():
|
|
117
|
+
self._send_json({"detail": f"File not found: {paths[0]}"}, 404)
|
|
118
|
+
return
|
|
119
|
+
try:
|
|
120
|
+
raw = filepath.read_text()
|
|
121
|
+
data = json.loads(raw)
|
|
122
|
+
except json.JSONDecodeError as e:
|
|
123
|
+
self._send_json({"detail": f"Invalid JSON: {e}"}, 500)
|
|
124
|
+
return
|
|
125
|
+
self._send_json(data)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def start_viewer_server(
|
|
129
|
+
host: str | None = None,
|
|
130
|
+
port: int | None = None,
|
|
131
|
+
) -> ViewerServer:
|
|
132
|
+
"""Start a trajectory viewer web server.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
host: Host to bind to. Defaults to ``"localhost"``.
|
|
136
|
+
port: Port to bind to. If *None*, a free port is chosen automatically.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
A :class:`ViewerServer` handle.
|
|
140
|
+
"""
|
|
141
|
+
host = host or "localhost"
|
|
142
|
+
port = port or _find_free_port()
|
|
143
|
+
|
|
144
|
+
httpd = _ThreadedHTTPServer((host, port), _Handler)
|
|
145
|
+
|
|
146
|
+
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
147
|
+
thread.start()
|
|
148
|
+
|
|
149
|
+
return ViewerServer(httpd, thread, host, port)
|