code-analysis-client 1.6.56__tar.gz → 1.6.58__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 (25) hide show
  1. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/PKG-INFO +37 -3
  2. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/README.md +36 -2
  3. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/__init__.py +11 -1
  4. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/client.py +84 -19
  5. code_analysis_client-1.6.58/code_analysis_client/exceptions.py +108 -0
  6. code_analysis_client-1.6.58/code_analysis_client/queue_wait.py +256 -0
  7. code_analysis_client-1.6.58/code_analysis_client/version.txt +1 -0
  8. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/PKG-INFO +37 -3
  9. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/SOURCES.txt +1 -0
  10. code_analysis_client-1.6.56/code_analysis_client/exceptions.py +0 -21
  11. code_analysis_client-1.6.56/code_analysis_client/version.txt +0 -1
  12. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/commands_proxy.py +0 -0
  13. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/config.py +0 -0
  14. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/file_session.py +0 -0
  15. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/py.typed +0 -0
  16. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/responses.py +0 -0
  17. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/server_api.py +0 -0
  18. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/server_schema.py +0 -0
  19. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/universal_file.py +0 -0
  20. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/validation.py +0 -0
  21. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/dependency_links.txt +0 -0
  22. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/requires.txt +0 -0
  23. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/top_level.txt +0 -0
  24. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/pyproject.toml +0 -0
  25. {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.6.56
3
+ Version: 1.6.58
4
4
  Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -54,7 +54,41 @@ from code_analysis_client import CodeAnalysisAsyncClient
54
54
  client = CodeAnalysisAsyncClient.from_server_config(config_dict, timeout=60.0)
55
55
  ```
56
56
 
57
- Queued/long commands: use `client.call_unified(..., expect_queue=True, auto_poll=True)` or the underlying `client.rpc.execute_command_unified(...)`.
57
+ ### Queued commands are handled automatically
58
+
59
+ Every entry point — `call`, `call_validated`, `client.commands.<name>`, and the
60
+ `file_sessions` / `universal_files` facades built on `call_validated` — routes
61
+ through one queue-aware core. If the server's immediate response is a queued-job
62
+ envelope (either deployed shape: `poll_with`/`store: "queuemgr"`, or
63
+ `queued_after_timeout`), the client polls `queue_get_job_status` for you until
64
+ the job reaches a terminal state, then returns the unwrapped inner result — the
65
+ same shape you'd get from a synchronous call. You never see the raw envelope.
66
+
67
+ ```python
68
+ # No special handling needed: queued or not, this returns the real result.
69
+ out = await client.call("some_long_running_command", {...})
70
+ ```
71
+
72
+ Failures raise instead of returning an error envelope:
73
+
74
+ * `CommandFailedError` — the job completed but the command itself failed
75
+ (`inner result {"success": false}` / `command_success is False` /
76
+ `completed_with_error`). Carries `.command`, `.job_id`, `.error`.
77
+ * `JobFailedError` — the job failed/stopped/cancelled, or reported `error`.
78
+ Carries `.job_id`, `.error`, `.status`.
79
+ * `JobTimeoutError` — only raised when you pass an explicit `timeout` and it
80
+ elapses; the job keeps running server-side. By default (`timeout=None`) the
81
+ client polls until the job finishes, however long that takes.
82
+
83
+ Optional keyword args on `call` / `call_validated` (and their `call_unified*`
84
+ counterparts): `timeout` (seconds, default `None` = wait until terminal),
85
+ `poll_interval` (seconds between polls, default `1.0`), `status_hook` (sync or
86
+ async callable invoked with each poll's status dict).
87
+
88
+ `call_unified` / `call_unified_validated` are kept as **deprecated aliases** of
89
+ `call` / `call_validated` for backward compatibility — `expect_queue` and
90
+ `auto_poll` are accepted but ignored, since queue handling is now always on.
91
+ Prefer `call` / `call_validated` directly.
58
92
 
59
93
  ## Validation using the server schema
60
94
 
@@ -73,7 +107,7 @@ async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
73
107
  client.clear_command_schema_cache()
74
108
  ```
75
109
 
76
- Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
110
+ Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
77
111
 
78
112
  ## High-level facades (aligned with live server registry)
79
113
 
@@ -42,7 +42,41 @@ from code_analysis_client import CodeAnalysisAsyncClient
42
42
  client = CodeAnalysisAsyncClient.from_server_config(config_dict, timeout=60.0)
43
43
  ```
44
44
 
45
- Queued/long commands: use `client.call_unified(..., expect_queue=True, auto_poll=True)` or the underlying `client.rpc.execute_command_unified(...)`.
45
+ ### Queued commands are handled automatically
46
+
47
+ Every entry point — `call`, `call_validated`, `client.commands.<name>`, and the
48
+ `file_sessions` / `universal_files` facades built on `call_validated` — routes
49
+ through one queue-aware core. If the server's immediate response is a queued-job
50
+ envelope (either deployed shape: `poll_with`/`store: "queuemgr"`, or
51
+ `queued_after_timeout`), the client polls `queue_get_job_status` for you until
52
+ the job reaches a terminal state, then returns the unwrapped inner result — the
53
+ same shape you'd get from a synchronous call. You never see the raw envelope.
54
+
55
+ ```python
56
+ # No special handling needed: queued or not, this returns the real result.
57
+ out = await client.call("some_long_running_command", {...})
58
+ ```
59
+
60
+ Failures raise instead of returning an error envelope:
61
+
62
+ * `CommandFailedError` — the job completed but the command itself failed
63
+ (`inner result {"success": false}` / `command_success is False` /
64
+ `completed_with_error`). Carries `.command`, `.job_id`, `.error`.
65
+ * `JobFailedError` — the job failed/stopped/cancelled, or reported `error`.
66
+ Carries `.job_id`, `.error`, `.status`.
67
+ * `JobTimeoutError` — only raised when you pass an explicit `timeout` and it
68
+ elapses; the job keeps running server-side. By default (`timeout=None`) the
69
+ client polls until the job finishes, however long that takes.
70
+
71
+ Optional keyword args on `call` / `call_validated` (and their `call_unified*`
72
+ counterparts): `timeout` (seconds, default `None` = wait until terminal),
73
+ `poll_interval` (seconds between polls, default `1.0`), `status_hook` (sync or
74
+ async callable invoked with each poll's status dict).
75
+
76
+ `call_unified` / `call_unified_validated` are kept as **deprecated aliases** of
77
+ `call` / `call_validated` for backward compatibility — `expect_queue` and
78
+ `auto_poll` are accepted but ignored, since queue handling is now always on.
79
+ Prefer `call` / `call_validated` directly.
46
80
 
47
81
  ## Validation using the server schema
48
82
 
@@ -61,7 +95,7 @@ async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
61
95
  client.clear_command_schema_cache()
62
96
  ```
63
97
 
64
- Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
98
+ Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
65
99
 
66
100
  ## High-level facades (aligned with live server registry)
67
101
 
@@ -16,7 +16,13 @@ from code_analysis_client.config import (
16
16
  adapter_settings_to_jsonrpc_kwargs,
17
17
  load_server_config,
18
18
  )
19
- from code_analysis_client.exceptions import ClientValidationError
19
+ from code_analysis_client.exceptions import (
20
+ ClientValidationError,
21
+ CommandFailedError,
22
+ JobFailedError,
23
+ JobTimeoutError,
24
+ QueueJobError,
25
+ )
20
26
  from code_analysis_client.file_session import FileSessionClient, SessionNotFoundError
21
27
  from code_analysis_client.server_api import (
22
28
  CLIENT_FACADE_COMMANDS,
@@ -43,10 +49,14 @@ __all__ = [
43
49
  "CST_REMOVED_COMMANDS",
44
50
  "ClientValidationError",
45
51
  "CodeAnalysisAsyncClient",
52
+ "CommandFailedError",
46
53
  "FILE_SESSION_COMMANDS",
47
54
  "FILE_SESSION_FACADE_METHODS",
48
55
  "FileSessionClient",
56
+ "JobFailedError",
57
+ "JobTimeoutError",
49
58
  "LEGACY_REMOVED_COMMANDS",
59
+ "QueueJobError",
50
60
  "REMOVED_COMMANDS",
51
61
  "SessionNotFoundError",
52
62
  "TRANSFER_FACADE_METHODS",
@@ -8,7 +8,7 @@ email: vasilyvz@gmail.com
8
8
  from __future__ import annotations
9
9
 
10
10
  from pathlib import Path
11
- from typing import Any, Callable, Dict, Mapping, Optional
11
+ from typing import Any, Dict, Mapping, Optional
12
12
 
13
13
  try:
14
14
  from mcp_proxy_adapter.client.jsonrpc_client.client import JsonRpcClient
@@ -22,6 +22,13 @@ from code_analysis_client.config import (
22
22
  load_server_config,
23
23
  )
24
24
  from code_analysis_client.file_session import FileSessionClient
25
+ from code_analysis_client.queue_wait import (
26
+ StatusHook,
27
+ extract_job_id,
28
+ is_queued_envelope,
29
+ unwrap_job_result,
30
+ wait_for_job,
31
+ )
25
32
  from code_analysis_client.universal_file import UniversalFileClient
26
33
  from code_analysis_client.server_schema import fetch_command_schema_from_server
27
34
  from code_analysis_client.validation import (
@@ -141,6 +148,44 @@ class CodeAnalysisAsyncClient:
141
148
  self._command_schema_cache[command] = schema
142
149
  return schema
143
150
 
151
+ async def _execute(
152
+ self,
153
+ command: str,
154
+ params: Optional[Dict[str, Any]] = None,
155
+ *,
156
+ use_cmd_endpoint: bool = False,
157
+ timeout: Optional[float] = None,
158
+ poll_interval: float = 1.0,
159
+ status_hook: Optional[StatusHook] = None,
160
+ ) -> Dict[str, Any]:
161
+ """Single queue-aware core every public entry point routes through.
162
+
163
+ Runs ``command`` via the raw adapter ``execute_command``. If the
164
+ immediate response is a queue-service envelope
165
+ (:func:`code_analysis_client.queue_wait.is_queued_envelope`), polls the
166
+ job to completion (:func:`~code_analysis_client.queue_wait.wait_for_job`)
167
+ and returns the unwrapped inner result
168
+ (:func:`~code_analysis_client.queue_wait.unwrap_job_result`), raising on
169
+ failure. A non-queued response is returned unchanged.
170
+ """
171
+ resp = await self._rpc.execute_command(
172
+ command,
173
+ params or {},
174
+ use_cmd_endpoint=use_cmd_endpoint,
175
+ )
176
+ if not is_queued_envelope(resp):
177
+ return resp
178
+
179
+ job_id = extract_job_id(resp)
180
+ status = await wait_for_job(
181
+ self._rpc,
182
+ job_id,
183
+ timeout=timeout,
184
+ poll_interval=poll_interval,
185
+ status_hook=status_hook,
186
+ )
187
+ return await unwrap_job_result(status, rpc=self._rpc)
188
+
144
189
  async def call_validated(
145
190
  self,
146
191
  command: str,
@@ -148,16 +193,22 @@ class CodeAnalysisAsyncClient:
148
193
  *,
149
194
  use_cmd_endpoint: bool = False,
150
195
  refresh_schema: bool = False,
196
+ timeout: Optional[float] = None,
197
+ poll_interval: float = 1.0,
198
+ status_hook: Optional[StatusHook] = None,
151
199
  ) -> Dict[str, Any]:
152
- """``help`` → schema on server, shallow local validation, then ``execute_command``."""
200
+ """``help`` → schema on server, shallow local validation, then the queue-aware core."""
153
201
  schema = await self.get_command_schema(command, refresh=refresh_schema)
154
202
  merged = dict(params or {})
155
203
  prepared = prepare_params_for_schema(merged, schema)
156
204
  validate_params_against_schema(prepared, schema, command_name=command)
157
- return await self._rpc.execute_command(
205
+ return await self._execute(
158
206
  command,
159
207
  prepared,
160
208
  use_cmd_endpoint=use_cmd_endpoint,
209
+ timeout=timeout,
210
+ poll_interval=poll_interval,
211
+ status_hook=status_hook,
161
212
  )
162
213
 
163
214
  async def call_unified_validated(
@@ -171,21 +222,25 @@ class CodeAnalysisAsyncClient:
171
222
  auto_poll: bool = True,
172
223
  poll_interval: float = 1.0,
173
224
  timeout: Optional[float] = None,
174
- status_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
225
+ status_hook: Optional[StatusHook] = None,
175
226
  ) -> Dict[str, Any]:
176
- """Same as :meth:`call_validated` but uses ``execute_command_unified``."""
227
+ """Deprecated alias for :meth:`call_validated`.
228
+
229
+ ``expect_queue`` and ``auto_poll`` are accepted only for backward
230
+ compatibility and are ignored: every path is now queue-aware
231
+ unconditionally through the shared :meth:`_execute` core (no more
232
+ adapter ``execute_command_unified``). Prefer :meth:`call_validated`.
233
+ """
177
234
  schema = await self.get_command_schema(command, refresh=refresh_schema)
178
235
  merged = dict(params or {})
179
236
  prepared = prepare_params_for_schema(merged, schema)
180
237
  validate_params_against_schema(prepared, schema, command_name=command)
181
- return await self._rpc.execute_command_unified(
238
+ return await self._execute(
182
239
  command,
183
240
  prepared,
184
241
  use_cmd_endpoint=use_cmd_endpoint,
185
- expect_queue=expect_queue,
186
- auto_poll=auto_poll,
187
- poll_interval=poll_interval,
188
242
  timeout=timeout,
243
+ poll_interval=poll_interval,
189
244
  status_hook=status_hook,
190
245
  )
191
246
 
@@ -195,12 +250,18 @@ class CodeAnalysisAsyncClient:
195
250
  params: Optional[Dict[str, Any]] = None,
196
251
  *,
197
252
  use_cmd_endpoint: bool = False,
253
+ timeout: Optional[float] = None,
254
+ poll_interval: float = 1.0,
255
+ status_hook: Optional[StatusHook] = None,
198
256
  ) -> Dict[str, Any]:
199
- """Run any registered server command (sync completion)."""
200
- return await self._rpc.execute_command(
257
+ """Run any registered server command; queued jobs are polled to completion."""
258
+ return await self._execute(
201
259
  command,
202
- params or {},
260
+ params,
203
261
  use_cmd_endpoint=use_cmd_endpoint,
262
+ timeout=timeout,
263
+ poll_interval=poll_interval,
264
+ status_hook=status_hook,
204
265
  )
205
266
 
206
267
  async def call_unified(
@@ -213,17 +274,21 @@ class CodeAnalysisAsyncClient:
213
274
  auto_poll: bool = True,
214
275
  poll_interval: float = 1.0,
215
276
  timeout: Optional[float] = None,
216
- status_hook: Optional[Callable[[Dict[str, Any]], Any]] = None,
277
+ status_hook: Optional[StatusHook] = None,
217
278
  ) -> Dict[str, Any]:
218
- """Run a command with optional queue detection and polling (adapter unified path)."""
219
- return await self._rpc.execute_command_unified(
279
+ """Deprecated alias for :meth:`call`.
280
+
281
+ ``expect_queue`` and ``auto_poll`` are accepted only for backward
282
+ compatibility and are ignored: every path is now queue-aware
283
+ unconditionally through the shared :meth:`_execute` core (no more
284
+ adapter ``execute_command_unified``). Prefer :meth:`call`.
285
+ """
286
+ return await self._execute(
220
287
  command,
221
- params or {},
288
+ params,
222
289
  use_cmd_endpoint=use_cmd_endpoint,
223
- expect_queue=expect_queue,
224
- auto_poll=auto_poll,
225
- poll_interval=poll_interval,
226
290
  timeout=timeout,
291
+ poll_interval=poll_interval,
227
292
  status_hook=status_hook,
228
293
  )
229
294
 
@@ -0,0 +1,108 @@
1
+ """
2
+ Client-side errors: parameter-validation failures and queued-job runtime errors.
3
+
4
+ No dependency on the code_analysis server package.
5
+
6
+ Author: Vasiliy Zdanovskiy
7
+ email: vasilyvz@gmail.com
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Optional
13
+
14
+
15
+ class ClientValidationError(ValueError):
16
+ """Parameters do not match the command JSON schema (from server ``help``)."""
17
+
18
+ def __init__(
19
+ self,
20
+ message: str,
21
+ *,
22
+ field: Optional[str] = None,
23
+ details: Optional[Dict[str, Any]] = None,
24
+ ) -> None:
25
+ """Initialize validation error with optional field and details payload."""
26
+ super().__init__(message)
27
+ self.field = field
28
+ self.details = details or {}
29
+
30
+
31
+ class QueueJobError(RuntimeError):
32
+ """Base for queued-job runtime errors (job failure, timeout, command failure).
33
+
34
+ Deliberately does **not** inherit :class:`ClientValidationError`. That class
35
+ represents bad input caught before a call is even sent, and an
36
+ ``except ClientValidationError:`` handler written for that purpose must not
37
+ silently swallow a runtime failure that happened server-side after a valid
38
+ call was queued and polled. Catch ``QueueJobError`` (or its subclasses)
39
+ separately from parameter-validation errors.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ message: str,
45
+ *,
46
+ job_id: Optional[str] = None,
47
+ details: Optional[Dict[str, Any]] = None,
48
+ ) -> None:
49
+ """Initialize with message, the job id involved, and a details payload."""
50
+ super().__init__(message)
51
+ self.job_id = job_id
52
+ self.details = details or {}
53
+
54
+
55
+ class JobFailedError(QueueJobError):
56
+ """A queued job failed/stopped/cancelled, or reported an ``error`` status."""
57
+
58
+ def __init__(
59
+ self,
60
+ job_id: Optional[str],
61
+ error: Any = None,
62
+ *,
63
+ status: Optional[str] = None,
64
+ ) -> None:
65
+ """Store job id, verbatim error payload, and terminal status."""
66
+ message = f"Job {job_id!r} failed (status={status!r}): {error!r}"
67
+ super().__init__(
68
+ message,
69
+ job_id=job_id,
70
+ details={"job_id": job_id, "status": status, "error": error},
71
+ )
72
+ self.error = error
73
+ self.status = status
74
+
75
+
76
+ class JobTimeoutError(QueueJobError):
77
+ """Polling exceeded ``timeout``; the job keeps running server-side."""
78
+
79
+ def __init__(self, job_id: Optional[str], timeout: Optional[float]) -> None:
80
+ """Store job id and the timeout that was exceeded."""
81
+ message = (
82
+ f"Timed out after {timeout}s waiting for job {job_id!r}; "
83
+ "the job keeps running server-side - poll queue_get_job_status "
84
+ "manually or retry with a longer timeout."
85
+ )
86
+ super().__init__(
87
+ message,
88
+ job_id=job_id,
89
+ details={"job_id": job_id, "timeout": timeout},
90
+ )
91
+ self.timeout = timeout
92
+
93
+
94
+ class CommandFailedError(QueueJobError):
95
+ """A queued command completed but its inner result is an error envelope."""
96
+
97
+ def __init__(
98
+ self, command: Optional[str], job_id: Optional[str], error: Any
99
+ ) -> None:
100
+ """Store command name, job id, and the verbatim inner error object."""
101
+ message = f"Command {command!r} failed (job_id={job_id!r}): {error!r}"
102
+ super().__init__(
103
+ message,
104
+ job_id=job_id,
105
+ details={"command": command, "job_id": job_id, "error": error},
106
+ )
107
+ self.command = command
108
+ self.error = error
@@ -0,0 +1,256 @@
1
+ """
2
+ Queue-aware polling core: detect queued-job envelopes, poll them to completion,
3
+ and unwrap the inner command result.
4
+
5
+ Author: Vasiliy Zdanovskiy
6
+ email: vasilyvz@gmail.com
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import inspect
13
+ import time
14
+ from typing import Any, Awaitable, Callable, Dict, Optional, Union
15
+
16
+ from code_analysis_client.exceptions import (
17
+ CommandFailedError,
18
+ JobFailedError,
19
+ JobTimeoutError,
20
+ )
21
+
22
+ # Keys a pure queue-service envelope may carry. A response whose keys are NOT a
23
+ # subset of this set carries real payload data alongside job_id/status and must
24
+ # never be mistaken for a queued placeholder (e.g. `search`, which returns both
25
+ # `job_id` for pagination and actual result items).
26
+ _SERVICE_ENVELOPE_KEYS = {
27
+ "success",
28
+ "job_id",
29
+ "jobId",
30
+ "status",
31
+ "message",
32
+ "store",
33
+ "poll_with",
34
+ "queued_after_timeout",
35
+ "data",
36
+ }
37
+
38
+ _QUEUED_STATUSES = {"pending", "queued", "running"}
39
+ _TERMINAL_STATUSES = {"completed", "failed", "stopped", "cancelled"}
40
+ _FAILED_JOB_STATUSES = {"failed", "stopped", "cancelled"}
41
+
42
+ StatusHook = Callable[[Dict[str, Any]], Union[Any, Awaitable[Any]]]
43
+
44
+
45
+ def is_queued_envelope(resp: Dict[str, Any]) -> bool:
46
+ """Return True when ``resp`` is a queue-service envelope (either deployed variant).
47
+
48
+ Detection rule: a ``job_id``/``jobId`` is present (top level or under
49
+ ``data``) AND one of:
50
+ * ``poll_with == "queue_get_job_status"``
51
+ * ``store == "queuemgr"``
52
+ * ``queued_after_timeout`` is truthy
53
+ * ``status`` is one of pending/queued/running AND the envelope's own keys
54
+ are a subset of the known queue-service key set (this subset guard is
55
+ what prevents false positives on commands like ``search`` whose
56
+ legitimate result also carries a ``job_id`` plus real payload keys).
57
+
58
+ Known out-of-scope gap: this only recognizes the two deployed variants
59
+ above, both of which carry an explicit marker (``poll_with``/``store``/
60
+ ``queued_after_timeout``) or a top-level ``status`` alongside a
61
+ service-only key set. An initial response whose pending status and
62
+ ``job_id`` live ONLY under ``data`` with no top-level marker at all (e.g.
63
+ ``{"success": true, "data": {"job_id": ..., "status": "pending"}}`` and
64
+ nothing else at top level) is NOT one of the two documented variants and
65
+ is therefore NOT detected as queued — it would be returned as-is instead
66
+ of polled. If a third server-side queueing shape ever ships in that form,
67
+ this function must be extended explicitly; it does not infer queuing from
68
+ ``data``-nested status alone, to avoid reintroducing false positives on
69
+ ordinary nested-data command results.
70
+ """
71
+ if not isinstance(resp, dict):
72
+ return False
73
+
74
+ job_id = resp.get("job_id") or resp.get("jobId")
75
+ if not job_id:
76
+ data = resp.get("data")
77
+ if isinstance(data, dict):
78
+ job_id = data.get("job_id") or data.get("jobId")
79
+ if not job_id:
80
+ return False
81
+
82
+ if resp.get("poll_with") == "queue_get_job_status":
83
+ return True
84
+ if resp.get("store") == "queuemgr":
85
+ return True
86
+ if resp.get("queued_after_timeout"):
87
+ return True
88
+
89
+ status = resp.get("status")
90
+ if status in _QUEUED_STATUSES and set(resp.keys()) <= _SERVICE_ENVELOPE_KEYS:
91
+ return True
92
+
93
+ return False
94
+
95
+
96
+ def extract_job_id(resp: Dict[str, Any]) -> str:
97
+ """Pull the job identifier out of a queued envelope (top level or under data).
98
+
99
+ Public so callers that already confirmed :func:`is_queued_envelope` (e.g.
100
+ the client core's ``_execute``) reuse this instead of duplicating the
101
+ top-level/``data``-nested lookup inline.
102
+ """
103
+ job_id = resp.get("job_id") or resp.get("jobId")
104
+ if not job_id:
105
+ data = resp.get("data")
106
+ if isinstance(data, dict):
107
+ job_id = data.get("job_id") or data.get("jobId")
108
+ return str(job_id)
109
+
110
+
111
+ async def _call_status_hook(
112
+ status_hook: Optional[StatusHook], data: Dict[str, Any]
113
+ ) -> None:
114
+ """Invoke ``status_hook`` with ``data``, awaiting it when it returns a coroutine."""
115
+ if status_hook is None:
116
+ return
117
+ maybe = status_hook(data)
118
+ if inspect.isawaitable(maybe):
119
+ await maybe
120
+
121
+
122
+ async def wait_for_job(
123
+ rpc: Any,
124
+ job_id: str,
125
+ *,
126
+ timeout: Optional[float] = None,
127
+ poll_interval: float = 1.0,
128
+ status_hook: Optional[StatusHook] = None,
129
+ ) -> Dict[str, Any]:
130
+ """Poll ``queue_get_job_status`` via the raw adapter ``rpc`` until the job is terminal.
131
+
132
+ Uses ``rpc.execute_command`` directly (never the queue-aware core) to avoid
133
+ recursing back into job polling. ``timeout=None`` (default) polls until the
134
+ job reaches a terminal state, per the user's requirement. If ``timeout`` is
135
+ set and exceeded, raises :class:`JobTimeoutError` — the job keeps running
136
+ server-side.
137
+ """
138
+ start = time.monotonic() if timeout is not None else None
139
+ while True:
140
+ resp = await rpc.execute_command("queue_get_job_status", {"job_id": job_id})
141
+ data = resp.get("data") if isinstance(resp, dict) else None
142
+ if not isinstance(data, dict):
143
+ data = resp if isinstance(resp, dict) else {}
144
+
145
+ await _call_status_hook(status_hook, data)
146
+
147
+ status = str(data.get("status", "")).strip().lower()
148
+ if status in _TERMINAL_STATUSES:
149
+ return data
150
+
151
+ if timeout is not None and start is not None:
152
+ if (time.monotonic() - start) >= timeout:
153
+ raise JobTimeoutError(job_id, timeout)
154
+
155
+ await asyncio.sleep(poll_interval)
156
+
157
+
158
+ _JOB_FAILED_REFETCH_ATTEMPTS = 4
159
+ _JOB_FAILED_REFETCH_SLEEP_SECONDS = 0.5
160
+
161
+
162
+ def _extract_inner_command_error(status_data: Dict[str, Any]) -> Any:
163
+ """Pull the failed command's own structured error out of a job status dict.
164
+
165
+ Looks at ``status_data["result"]["result"]["error"]`` first (outer
166
+ ``result`` is the queue wrapper, inner ``result`` is the command's own
167
+ result envelope), falling back to ``status_data["result"]["error"]``.
168
+ Returns ``None`` when neither is present.
169
+ """
170
+ result_field = status_data.get("result")
171
+ if not isinstance(result_field, dict):
172
+ return None
173
+ inner_result = result_field.get("result")
174
+ if isinstance(inner_result, dict) and inner_result.get("error") is not None:
175
+ return inner_result["error"]
176
+ if result_field.get("error") is not None:
177
+ return result_field["error"]
178
+ return None
179
+
180
+
181
+ async def _refetch_inner_command_error(rpc: Any, job_id: str) -> Any:
182
+ """Re-poll ``queue_get_job_status`` hunting for the inner structured error.
183
+
184
+ Write-order gotcha: the queue store writes the job's terminal ``status``
185
+ slightly BEFORE it writes the ``result`` payload. A caller that reads the
186
+ status the instant it turns ``failed`` can therefore observe a terminal
187
+ status with no ``result`` (and no nested inner error) yet, even though the
188
+ failed command DID record a structured error - it just hasn't landed in
189
+ the store. Re-fetching a few times with a short sleep gives that second
190
+ write time to land instead of silently downgrading to ``None``.
191
+ """
192
+ for _ in range(_JOB_FAILED_REFETCH_ATTEMPTS):
193
+ await asyncio.sleep(_JOB_FAILED_REFETCH_SLEEP_SECONDS)
194
+ resp = await rpc.execute_command("queue_get_job_status", {"job_id": job_id})
195
+ data = resp.get("data") if isinstance(resp, dict) else None
196
+ if not isinstance(data, dict):
197
+ data = resp if isinstance(resp, dict) else {}
198
+ inner_error = _extract_inner_command_error(data)
199
+ if inner_error is not None:
200
+ return inner_error
201
+ return None
202
+
203
+
204
+ async def unwrap_job_result(
205
+ status_data: Dict[str, Any], *, rpc: Any = None
206
+ ) -> Dict[str, Any]:
207
+ """Extract the inner command result from a terminal job status ``data`` dict.
208
+
209
+ Raises :class:`JobFailedError` when the job itself failed/stopped/cancelled
210
+ or reported ``error``. Raises :class:`CommandFailedError` when the job
211
+ completed but the inner command result is itself an error envelope.
212
+ Otherwise returns the inner result dict, in the same shape a sync call
213
+ would have returned.
214
+
215
+ On job failure, before raising, this tries to attach the failed command's
216
+ OWN structured error (nested at ``data.result.result.error``, falling
217
+ back to ``data.result.error``) to ``JobFailedError.error`` instead of the
218
+ bare queue-level ``error`` field. When that nested error is not yet
219
+ present in ``status_data`` and ``rpc`` was supplied, it re-fetches the job
220
+ status up to :data:`_JOB_FAILED_REFETCH_ATTEMPTS` times (see
221
+ :func:`_refetch_inner_command_error` for the write-order race this rides
222
+ out). The queue-level ``error`` field is used as a fallback when no
223
+ structured inner error is ever found; ``JobFailedError.error`` is ``None``
224
+ only when genuinely nothing was found either way.
225
+ """
226
+ job_id = status_data.get("job_id")
227
+ status = str(status_data.get("status", "")).strip().lower()
228
+ error = status_data.get("error")
229
+
230
+ if status in _FAILED_JOB_STATUSES or error:
231
+ inner_error = _extract_inner_command_error(status_data)
232
+ if inner_error is None and rpc is not None and job_id:
233
+ inner_error = await _refetch_inner_command_error(rpc, str(job_id))
234
+ raise JobFailedError(
235
+ job_id, inner_error if inner_error is not None else error, status=status
236
+ )
237
+
238
+ result_field = status_data.get("result")
239
+ if isinstance(result_field, dict) and "result" in result_field:
240
+ inner = result_field["result"]
241
+ else:
242
+ inner = result_field
243
+
244
+ inner_looks_failed = isinstance(inner, dict) and inner.get("success") is False
245
+ command_success_false = status_data.get("command_success") is False
246
+ completed_with_error = bool(status_data.get("completed_with_error")) or bool(
247
+ status_data.get("native_completed_with_error")
248
+ )
249
+
250
+ if inner_looks_failed or command_success_false or completed_with_error:
251
+ command = (
252
+ result_field.get("command") if isinstance(result_field, dict) else None
253
+ )
254
+ raise CommandFailedError(command, job_id, inner)
255
+
256
+ return inner if isinstance(inner, dict) else {"result": inner}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-analysis-client
3
- Version: 1.6.56
3
+ Version: 1.6.58
4
4
  Summary: Async JSON-RPC client for the code-analysis MCP server (mcp-proxy-adapter JsonRpcClient)
5
5
  Author-email: Vasiliy Zdanovskiy <vasilyvz@gmail.com>
6
6
  Requires-Python: >=3.10
@@ -54,7 +54,41 @@ from code_analysis_client import CodeAnalysisAsyncClient
54
54
  client = CodeAnalysisAsyncClient.from_server_config(config_dict, timeout=60.0)
55
55
  ```
56
56
 
57
- Queued/long commands: use `client.call_unified(..., expect_queue=True, auto_poll=True)` or the underlying `client.rpc.execute_command_unified(...)`.
57
+ ### Queued commands are handled automatically
58
+
59
+ Every entry point — `call`, `call_validated`, `client.commands.<name>`, and the
60
+ `file_sessions` / `universal_files` facades built on `call_validated` — routes
61
+ through one queue-aware core. If the server's immediate response is a queued-job
62
+ envelope (either deployed shape: `poll_with`/`store: "queuemgr"`, or
63
+ `queued_after_timeout`), the client polls `queue_get_job_status` for you until
64
+ the job reaches a terminal state, then returns the unwrapped inner result — the
65
+ same shape you'd get from a synchronous call. You never see the raw envelope.
66
+
67
+ ```python
68
+ # No special handling needed: queued or not, this returns the real result.
69
+ out = await client.call("some_long_running_command", {...})
70
+ ```
71
+
72
+ Failures raise instead of returning an error envelope:
73
+
74
+ * `CommandFailedError` — the job completed but the command itself failed
75
+ (`inner result {"success": false}` / `command_success is False` /
76
+ `completed_with_error`). Carries `.command`, `.job_id`, `.error`.
77
+ * `JobFailedError` — the job failed/stopped/cancelled, or reported `error`.
78
+ Carries `.job_id`, `.error`, `.status`.
79
+ * `JobTimeoutError` — only raised when you pass an explicit `timeout` and it
80
+ elapses; the job keeps running server-side. By default (`timeout=None`) the
81
+ client polls until the job finishes, however long that takes.
82
+
83
+ Optional keyword args on `call` / `call_validated` (and their `call_unified*`
84
+ counterparts): `timeout` (seconds, default `None` = wait until terminal),
85
+ `poll_interval` (seconds between polls, default `1.0`), `status_hook` (sync or
86
+ async callable invoked with each poll's status dict).
87
+
88
+ `call_unified` / `call_unified_validated` are kept as **deprecated aliases** of
89
+ `call` / `call_validated` for backward compatibility — `expect_queue` and
90
+ `auto_poll` are accepted but ignored, since queue handling is now always on.
91
+ Prefer `call` / `call_validated` directly.
58
92
 
59
93
  ## Validation using the server schema
60
94
 
@@ -73,7 +107,7 @@ async with CodeAnalysisAsyncClient(host="127.0.0.1", port=15001) as client:
73
107
  client.clear_command_schema_cache()
74
108
  ```
75
109
 
76
- Use `call_unified_validated` when you need queue polling. Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
110
+ Pass `refresh_schema=True` on a single call to bypass the in-memory schema cache.
77
111
 
78
112
  ## High-level facades (aligned with live server registry)
79
113
 
@@ -7,6 +7,7 @@ code_analysis_client/config.py
7
7
  code_analysis_client/exceptions.py
8
8
  code_analysis_client/file_session.py
9
9
  code_analysis_client/py.typed
10
+ code_analysis_client/queue_wait.py
10
11
  code_analysis_client/responses.py
11
12
  code_analysis_client/server_api.py
12
13
  code_analysis_client/server_schema.py
@@ -1,21 +0,0 @@
1
- """Client-side errors (no dependency on code_analysis server package)."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any, Dict, Optional
6
-
7
-
8
- class ClientValidationError(ValueError):
9
- """Parameters do not match the command JSON schema (from server ``help``)."""
10
-
11
- def __init__(
12
- self,
13
- message: str,
14
- *,
15
- field: Optional[str] = None,
16
- details: Optional[Dict[str, Any]] = None,
17
- ) -> None:
18
- """Initialize validation error with optional field and details payload."""
19
- super().__init__(message)
20
- self.field = field
21
- self.details = details or {}