code-analysis-client-dev 1.6.341__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.
@@ -0,0 +1,59 @@
1
+ """
2
+ Project-level typed methods on top of CodeAnalysisAsyncClient.
3
+
4
+ Wraps ``project_documentation_server_override`` (per-project address override of the documentation
5
+ server, used by live proofs that substitute an unreachable address).
6
+
7
+ Author: Vasiliy Zdanovskiy
8
+ email: vasilyvz@gmail.com
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any, Dict, Optional
14
+
15
+ from code_analysis_client.exceptions import ClientValidationError
16
+ from code_analysis_client.responses import FacadeCaller, unwrap_command_result
17
+
18
+ if TYPE_CHECKING:
19
+ from code_analysis_client.client import CodeAnalysisAsyncClient
20
+
21
+
22
+ class ProjectClient:
23
+ """Project-scoped commands that are not part of the session workflow."""
24
+
25
+ __slots__ = ("_client",)
26
+
27
+ def __init__(self, client: CodeAnalysisAsyncClient) -> None:
28
+ """Store the underlying async client."""
29
+ self._client = FacadeCaller(client) # type: ignore[assignment]
30
+
31
+ async def documentation_server_override(
32
+ self,
33
+ project_id: str,
34
+ *,
35
+ host: Optional[str] = None,
36
+ port: Optional[int] = None,
37
+ ) -> Dict[str, Any]:
38
+ """Set (``host`` and ``port``) or clear (neither) the documentation-server override.
39
+
40
+ Calls ``project_documentation_server_override``; returns ``project_id``, ``override``
41
+ (``None`` or ``{host, port}``) and ``effective`` (``None`` or ``{protocol, host, port}``).
42
+
43
+ Raises:
44
+ ClientValidationError: ``host`` and ``port`` were not supplied together.
45
+ """
46
+ if (host is None) != (port is None):
47
+ raise ClientValidationError(
48
+ "host and port must be supplied together or both omitted",
49
+ field="port" if host is not None else "host",
50
+ )
51
+ params: Dict[str, Any] = {"project_id": project_id}
52
+ if host is not None:
53
+ params["host"] = host
54
+ params["port"] = port
55
+ return unwrap_command_result(
56
+ await self._client.call_validated(
57
+ "project_documentation_server_override", params
58
+ )
59
+ )
File without changes
@@ -0,0 +1,341 @@
1
+ """
2
+ Queued-job handle and the single place that maps adapter results/errors onto client contracts.
3
+
4
+ Waiting itself is NOT done here: every wait is the adapter's standard call
5
+ (``JsonRpcClient.execute_command_unified`` for a new command, ``JsonRpcClient.wait_for_job_via_websocket``
6
+ for an existing job id). This module only turns what the adapter returns or raises into the
7
+ client's public result/exception contract:
8
+
9
+ * inner ``success=false`` -> :class:`~code_analysis_client.exceptions.CommandFailedError`
10
+ * job failed (the command's own error) -> :class:`~code_analysis_client.exceptions.CommandFailedError`
11
+ * job stopped / cancelled -> :class:`~code_analysis_client.exceptions.JobFailedError`
12
+ * caller wait timeout (job keeps running) -> :class:`~code_analysis_client.exceptions.JobTimeoutError`
13
+ * server JSON-RPC refusal -> :class:`~code_analysis_client.exceptions.ClientValidationError`
14
+ (mapped in ``client.py`` next to the transport call)
15
+
16
+ Author: Vasiliy Zdanovskiy
17
+ email: vasilyvz@gmail.com
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Awaitable, Callable, Dict, Optional, Union
25
+
26
+ from mcp_proxy_adapter.client.jsonrpc_client.exceptions import (
27
+ ClientRemoteResponseError,
28
+ CommandInnerFailureError,
29
+ CommandSessionFailedError,
30
+ CommandSessionTimeoutError,
31
+ )
32
+
33
+ from code_analysis_client.exceptions import (
34
+ ClientValidationError,
35
+ CommandFailedError,
36
+ JobFailedError,
37
+ JobTimeoutError,
38
+ )
39
+
40
+ _FAILED_JOB_STATUSES = frozenset({"failed", "stopped", "cancelled"})
41
+ _TERMINAL_JOB_STATUSES = frozenset({"completed"}) | _FAILED_JOB_STATUSES
42
+
43
+ #: Terminal command-session event name -> job status, used when the status payload has none.
44
+ _EVENT_STATUS = {
45
+ "command_failed": "failed",
46
+ "command_error": "failed",
47
+ "command_cancelled": "cancelled",
48
+ "job_failed": "failed",
49
+ "job_stopped": "stopped",
50
+ }
51
+
52
+ StatusHook = Callable[[Dict[str, Any]], Union[Any, Awaitable[Any]]]
53
+
54
+
55
+ def check_wait_arguments(timeout: Optional[float], poll_interval: Optional[float]) -> None:
56
+ """Reject a negative ``timeout`` and a non-positive ``poll_interval`` before anything is sent."""
57
+ if timeout is not None and (isinstance(timeout, bool) or timeout < 0):
58
+ raise ValueError(f"timeout must be >= 0 or None, got {timeout!r}")
59
+ if poll_interval is not None and (isinstance(poll_interval, bool) or poll_interval <= 0):
60
+ raise ValueError(f"poll_interval must be > 0 or None, got {poll_interval!r}")
61
+
62
+
63
+ def inner_command_result(value: Any) -> Dict[str, Any]:
64
+ """Return the command's own result envelope from a queued-job result value.
65
+
66
+ The queue stores ``{"job_id", "command", "result": <command envelope>, "status"}``; a value
67
+ without that wrapper is taken as the envelope itself. A non-dict result is wrapped as
68
+ ``{"result": value}`` so callers always get a dict.
69
+ """
70
+ inner = value["result"] if isinstance(value, dict) and "result" in value else value
71
+ return inner if isinstance(inner, dict) else {"result": inner}
72
+
73
+
74
+ def _extract_inner_command_error(status_data: Dict[str, Any]) -> Any:
75
+ """Pull the failed command's own structured error out of a job status dict.
76
+
77
+ Looks at ``status_data["result"]["result"]["error"]`` first (outer ``result`` is the queue
78
+ wrapper, inner ``result`` is the command's own envelope), then ``status_data["result"]["error"]``.
79
+ """
80
+ result_field = status_data.get("result")
81
+ if not isinstance(result_field, dict):
82
+ return None
83
+ inner = result_field.get("result")
84
+ if isinstance(inner, dict) and inner.get("error") is not None:
85
+ return inner["error"]
86
+ if result_field.get("error") is not None:
87
+ return result_field["error"]
88
+ return None
89
+
90
+
91
+ def remote_error_text(message: Any, code: Any) -> str:
92
+ """Text of a server JSON-RPC error, identical to the adapter's ``ClientRemoteResponseError``.
93
+
94
+ ``"JSON-RPC error: <message> (code: <code>)"`` - used for an error carried by a failed job so
95
+ that it reads exactly like the same error answered at the top level.
96
+ """
97
+ return f"JSON-RPC error: {message} (code: {code})"
98
+
99
+
100
+ def invalid_params_error(
101
+ message: str, data: Any, *, code: str | int = -32602
102
+ ) -> ClientValidationError:
103
+ """Build the :class:`ClientValidationError` for a server refusal of a command's parameters.
104
+
105
+ The single constructor for the top-level -32602 refusal (``client.py``), a -32602 carried by a
106
+ failed job (``job_failure``) and a refused command-session open with the server's own error
107
+ code (``map_adapter_error``), so ``code`` / ``field`` / ``details`` have one shape: ``details``
108
+ is the server's error ``data`` object and ``field`` its ``field`` entry (else ``"command"``).
109
+ Callers select the public code according to the command contract.
110
+ """
111
+ payload = data if isinstance(data, dict) else {}
112
+ field = payload.get("field")
113
+ return ClientValidationError(
114
+ message,
115
+ field=field if isinstance(field, str) else "command",
116
+ details=payload,
117
+ code=code,
118
+ )
119
+
120
+
121
+ def validation_error_code_for_command(command: Optional[str]) -> str | int:
122
+ """Return the command's public code for a remote invalid-parameter response."""
123
+ return -32602 if command in (None, "symbol_tree") else "VALIDATION_ERROR"
124
+
125
+
126
+ def command_failed(
127
+ command: Optional[str], job_id: Optional[str], inner: Dict[str, Any]
128
+ ) -> CommandFailedError:
129
+ """Build the :class:`CommandFailedError` for an inner envelope with ``success`` false."""
130
+ error = inner.get("error")
131
+ if error is None:
132
+ error = {k: inner[k] for k in ("code", "message") if k in inner} or inner
133
+ return CommandFailedError(command, job_id, error, result=inner)
134
+
135
+
136
+ def job_failure(
137
+ job_id: Optional[str],
138
+ status_data: Optional[Dict[str, Any]],
139
+ terminal_event: Optional[Dict[str, Any]] = None,
140
+ *,
141
+ command: Optional[str] = None,
142
+ ) -> Union[CommandFailedError, JobFailedError, ClientValidationError]:
143
+ """Build the error for a job that ended failed / stopped / cancelled.
144
+
145
+ A job whose structured error carries JSON-RPC ``-32602`` (the server validates after
146
+ enqueueing) yields :class:`ClientValidationError` exactly like a top-level refusal. A job that
147
+ ended ``failed`` (its command returned an error, or the job itself failed) yields
148
+ :class:`CommandFailedError` carrying the structured error, the same as an immediate command
149
+ that returns an error envelope (owner decision 2026-11-07, bug e58c5982, contract RC7). Only a
150
+ ``stopped`` / ``cancelled`` job yields :class:`JobFailedError`.
151
+
152
+ ``error`` is always the structured error object: the failed command's own error when the
153
+ status payload carries one, else the queue-level error (a bare string is wrapped as
154
+ ``{"message": ...}``), else ``{"message": "job <status>"}``.
155
+ """
156
+ status_data = status_data if isinstance(status_data, dict) else {}
157
+ terminal_event = terminal_event if isinstance(terminal_event, dict) else {}
158
+ status = str(status_data.get("status") or "").strip().lower()
159
+ if status not in _FAILED_JOB_STATUSES:
160
+ status = _EVENT_STATUS.get(str(terminal_event.get("event") or ""), status or "failed")
161
+ error = _extract_inner_command_error(status_data)
162
+ if error is None:
163
+ error = status_data.get("error")
164
+ if error is None:
165
+ error = terminal_event.get("error")
166
+ if not isinstance(error, dict):
167
+ error = {"message": str(error) if error else f"job {status}", "status": status}
168
+ if error.get("code") == -32602:
169
+ return invalid_params_error(
170
+ remote_error_text(error.get("message"), -32602),
171
+ error.get("data"),
172
+ code=validation_error_code_for_command(command),
173
+ )
174
+ if status == "failed":
175
+ return CommandFailedError(command, job_id, error)
176
+ return JobFailedError(job_id, error, status=status)
177
+
178
+
179
+ def result_from_job_status(
180
+ command: Optional[str], job_id: str, status_data: Dict[str, Any]
181
+ ) -> Dict[str, Any]:
182
+ """Turn a terminal job status payload into the call result, or raise the matching error."""
183
+ status = str(status_data.get("status") or "").strip().lower()
184
+ if status in _FAILED_JOB_STATUSES or status_data.get("error"):
185
+ raise job_failure(job_id, status_data, command=command)
186
+ inner = inner_command_result(status_data.get("result"))
187
+ flagged = (
188
+ status_data.get("command_success") is False
189
+ or bool(status_data.get("completed_with_error"))
190
+ or bool(status_data.get("native_completed_with_error"))
191
+ )
192
+ if inner.get("success") is False or flagged:
193
+ raise command_failed(command, job_id, inner)
194
+ return inner
195
+
196
+
197
+ def result_from_unified(
198
+ command: str, envelope: Dict[str, Any]
199
+ ) -> Dict[str, Any]:
200
+ """Return the command result from an ``execute_command_unified`` envelope.
201
+
202
+ An inner ``success=false`` raises :class:`CommandFailedError` (it never reaches the caller as
203
+ a dict), for the immediate and the queued mode alike.
204
+ """
205
+ value = envelope.get("result")
206
+ inner = value if envelope.get("mode") == "immediate" else inner_command_result(value)
207
+ if not isinstance(inner, dict):
208
+ inner = {"result": inner}
209
+ if inner.get("success") is False:
210
+ raise command_failed(command, envelope.get("job_id"), inner)
211
+ return inner
212
+
213
+
214
+ def map_adapter_error(
215
+ exc: BaseException, command: str, timeout: Optional[float]
216
+ ) -> Optional[BaseException]:
217
+ """Map an adapter exception onto the client's public exception, or ``None`` to re-raise as is.
218
+
219
+ * ``CommandSessionTimeoutError`` / ``asyncio.TimeoutError`` -> ``JobTimeoutError`` (the job keeps running)
220
+ * ``CommandInnerFailureError`` -> ``CommandFailedError``
221
+ * ``CommandSessionFailedError`` with a terminal event -> ``CommandFailedError`` when the job
222
+ ``failed``, ``JobFailedError`` when it was stopped / cancelled (structured error)
223
+ * ``CommandSessionFailedError`` for a refused open (numeric ``code``) -> ``ClientRemoteResponseError``
224
+ so the caller's JSON-RPC handling (-32602, -32601) is the same as on the HTTP path
225
+ * ``CommandSessionFailedError`` for a refused open carrying the server's own string error
226
+ ``code`` (the adapter forwards ``exc.code`` of the refusing exception, e.g. ``VALIDATION_ERROR``
227
+ from a command's ``validate_params`` before enqueue) -> ``ClientValidationError`` with that
228
+ exact ``code``, the server message and ``details`` = the server's ``data`` (the documented
229
+ refusal shape of :class:`ClientValidationError`)
230
+ """
231
+ if isinstance(exc, CommandSessionTimeoutError):
232
+ details = exc.details if isinstance(exc.details, dict) else {}
233
+ return JobTimeoutError(details.get("job_id"), details.get("timeout", timeout))
234
+ if isinstance(exc, asyncio.TimeoutError):
235
+ return JobTimeoutError(None, timeout)
236
+ if isinstance(exc, CommandInnerFailureError):
237
+ inner = exc.result if isinstance(exc.result, dict) else {}
238
+ if not isinstance(inner.get("error"), dict):
239
+ inner = {
240
+ "success": False,
241
+ "error": {
242
+ "code": exc.error_code,
243
+ "message": exc.error_message,
244
+ "data": exc.error_data,
245
+ },
246
+ }
247
+ return command_failed(exc.command, exc.job_id, inner)
248
+ if isinstance(exc, CommandSessionFailedError):
249
+ details = exc.details if isinstance(exc.details, dict) else {}
250
+ terminal = details.get("terminal_event")
251
+ if isinstance(terminal, dict):
252
+ job_id = terminal.get("job_id")
253
+ status_data = details.get("result_status")
254
+ if job_id is None and isinstance(status_data, dict):
255
+ job_id = status_data.get("job_id")
256
+ return job_failure(job_id, status_data, terminal, command=command)
257
+ code = details.get("code")
258
+ if isinstance(code, int) and not isinstance(code, bool):
259
+ return ClientRemoteResponseError(
260
+ f"JSON-RPC error: {exc} (code: {code})",
261
+ operation="command_session.open",
262
+ details={
263
+ "code": code,
264
+ "data": details.get("data"),
265
+ "command": command,
266
+ },
267
+ )
268
+ if isinstance(code, str) and code.strip():
269
+ return invalid_params_error(
270
+ str(details.get("error") or exc),
271
+ details.get("data"),
272
+ code=code.strip(),
273
+ )
274
+ return None
275
+
276
+
277
+ @dataclass
278
+ class QueuedJob:
279
+ """Handle for a queued job returned when ``auto_poll=False`` detaches from the job.
280
+
281
+ Returned by ``CodeAnalysisAsyncClient`` calls made with ``auto_poll=False`` (for every
282
+ command, queued or formerly immediate). ``job_id`` is a non-empty string and ``envelope`` the
283
+ adapter's acceptance envelope. :meth:`wait` blocks on the job and returns/raises exactly like
284
+ the default (auto-polling) path.
285
+ """
286
+
287
+ job_id: str
288
+ envelope: Dict[str, Any]
289
+ rpc: Any = field(repr=False)
290
+ #: Command name, used in :class:`CommandFailedError`.
291
+ command: Optional[str] = None
292
+ #: Result already produced when the adapter ran the command without a job (WS unreachable).
293
+ resolved: Optional[Dict[str, Any]] = field(default=None, repr=False)
294
+
295
+ async def wait(
296
+ self,
297
+ *,
298
+ timeout: Optional[float] = None,
299
+ poll_interval: Optional[float] = None,
300
+ status_hook: Optional[StatusHook] = None,
301
+ ) -> Dict[str, Any]:
302
+ """Wait for the job and return the command result, like the default call path.
303
+
304
+ Uses only public adapter API: one status fetch
305
+ (``fetch_command_session_result``) and, when the job is still running, the adapter's
306
+ WebSocket wait (``wait_for_job_via_websocket``) followed by one result fetch. Raises
307
+ :class:`JobTimeoutError` when ``timeout`` expires (the job keeps running),
308
+ :class:`JobFailedError` / :class:`CommandFailedError` like a direct call.
309
+
310
+ ``timeout=None`` resolves to the client config ``websocket.job_wait_timeout`` (0 = no
311
+ bound). ``poll_interval`` is accepted for signature symmetry with the call path; the
312
+ public WebSocket wait for an existing job id has no poll cadence and no RPC-polling
313
+ fallback, so it is not used here.
314
+ """
315
+ check_wait_arguments(timeout, poll_interval)
316
+ if self.resolved is not None:
317
+ return self.resolved
318
+ status_data = await self.rpc.fetch_command_session_result(self.job_id)
319
+ if str(status_data.get("status") or "").strip().lower() not in _TERMINAL_JOB_STATUSES:
320
+ try:
321
+ await self.rpc.wait_for_job_via_websocket(
322
+ self.job_id, timeout=timeout, on_inbound_message=status_hook
323
+ )
324
+ except (CommandSessionTimeoutError, asyncio.TimeoutError) as exc:
325
+ mapped = map_adapter_error(exc, self.command or "", timeout)
326
+ assert mapped is not None
327
+ if isinstance(mapped, JobTimeoutError) and mapped.job_id is None:
328
+ mapped = JobTimeoutError(self.job_id, timeout)
329
+ raise mapped from exc
330
+ status_data = await self.rpc.fetch_command_session_result(self.job_id)
331
+ return result_from_job_status(self.command, self.job_id, status_data)
332
+
333
+ async def status(self) -> Dict[str, Any]:
334
+ """One-shot ``queue_get_job_status`` fetch (no waiting)."""
335
+ resp = await self.rpc.execute_command(
336
+ "queue_get_job_status", {"job_id": self.job_id}
337
+ )
338
+ data = resp.get("data") if isinstance(resp, dict) else None
339
+ if not isinstance(data, dict):
340
+ data = resp if isinstance(resp, dict) else {}
341
+ return data
@@ -0,0 +1,126 @@
1
+ """Parse JSON-RPC command envelopes from code-analysis-server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict
6
+
7
+ from code_analysis_client.exceptions import (
8
+ ClientValidationError,
9
+ CommandFailedError,
10
+ VectorizerUnavailableError,
11
+ )
12
+
13
+
14
+ def command_error_code(data: Dict[str, Any]) -> str:
15
+ """Return domain or JSON-RPC error code from a failed command response."""
16
+ if data.get("success") is True:
17
+ return ""
18
+ top = data.get("code")
19
+ if top is not None and str(top).strip():
20
+ return str(top).strip()
21
+ err = data.get("error")
22
+ if isinstance(err, dict):
23
+ nested = err.get("code")
24
+ if nested is not None:
25
+ return str(nested).strip()
26
+ if isinstance(err, str) and err.strip():
27
+ return err.strip()
28
+ return ""
29
+
30
+
31
+ def vectorizer_error_details(data: Dict[str, Any]) -> Dict[str, Any]:
32
+ """Return the ``details`` object of a failed command response.
33
+
34
+ The server (mcp-proxy-adapter ``ErrorResult``) puts the details at ``error.data``; a flat
35
+ envelope may carry ``details`` at the top level or ``error.details``. All three are read, in
36
+ the order ``error.data``, ``error.details``, top-level ``details``.
37
+ """
38
+ err = data.get("error")
39
+ if isinstance(err, dict):
40
+ for key in ("data", "details"):
41
+ nested = err.get(key)
42
+ if isinstance(nested, dict):
43
+ return nested
44
+ top = data.get("details")
45
+ return top if isinstance(top, dict) else {}
46
+
47
+
48
+ def unwrap_command_result(
49
+ data: Dict[str, Any],
50
+ *,
51
+ session_not_found_type: type[ClientValidationError] | None = None,
52
+ ) -> Dict[str, Any]:
53
+ """Return success ``data`` dict or raise :class:`ClientValidationError`.
54
+
55
+ A refusal raises with ``.code`` equal to the server error code and ``.details`` equal to the
56
+ server's error details object (``{}`` when it carries none).
57
+ """
58
+ if data.get("success"):
59
+ inner = data.get("data")
60
+ return inner if isinstance(inner, dict) else data
61
+ code = command_error_code(data)
62
+ message = data.get("message")
63
+ err = data.get("error")
64
+ if message is None and isinstance(err, dict):
65
+ message = err.get("message")
66
+ if message is None:
67
+ message = str(data)
68
+ server_details = vectorizer_error_details(data)
69
+ if code == "SESSION_NOT_FOUND" and session_not_found_type is not None:
70
+ raise session_not_found_type(
71
+ message, field="session_id", details=server_details, code=code
72
+ )
73
+ if code == "VECTORIZER_UNAVAILABLE":
74
+ details = vectorizer_error_details(data)
75
+ raise VectorizerUnavailableError(
76
+ message,
77
+ service=details.get("service"),
78
+ services=details.get("services", []),
79
+ reason=details.get("reason"),
80
+ since=details.get("since"),
81
+ last_probe_at=details.get("last_probe_at"),
82
+ retry_after_seconds=details.get("retry_after_seconds"),
83
+ details=details,
84
+ )
85
+ raise ClientValidationError(
86
+ message, field="command", details=server_details, code=code or None
87
+ )
88
+
89
+
90
+ class FacadeCaller:
91
+ """Client view handed to the typed facades (sessions, worktrees, check chains, previews).
92
+
93
+ The facades map a server refusal (``SESSION_NOT_FOUND``, ``VECTORIZER_UNAVAILABLE``,
94
+ ``WORKTREE_SYNC_CONFLICT``, ...) from the failed inner envelope. The public ``call`` /
95
+ ``call_validated`` raise :class:`CommandFailedError` for such an envelope; this view hands the
96
+ envelope (``CommandFailedError.result``) back to the facade unchanged so its typed errors stay
97
+ exactly as they were.
98
+ """
99
+
100
+ __slots__ = ("_client",)
101
+
102
+ def __init__(self, client: Any) -> None:
103
+ """Wrap ``client``."""
104
+ self._client = client
105
+
106
+ def __getattr__(self, name: str) -> Any:
107
+ """Forward everything else to the real client."""
108
+ return getattr(self._client, name)
109
+
110
+ async def call_validated(self, *args: Any, **kwargs: Any) -> Any:
111
+ """``call_validated`` that returns a failed inner envelope instead of raising."""
112
+ try:
113
+ return await self._client.call_validated(*args, **kwargs)
114
+ except CommandFailedError as exc:
115
+ if isinstance(exc.result, dict):
116
+ return exc.result
117
+ raise
118
+
119
+ async def call(self, *args: Any, **kwargs: Any) -> Any:
120
+ """``call`` that returns a failed inner envelope instead of raising."""
121
+ try:
122
+ return await self._client.call(*args, **kwargs)
123
+ except CommandFailedError as exc:
124
+ if isinstance(exc.result, dict):
125
+ return exc.result
126
+ raise