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.
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/PKG-INFO +37 -3
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/README.md +36 -2
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/__init__.py +11 -1
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/client.py +84 -19
- code_analysis_client-1.6.58/code_analysis_client/exceptions.py +108 -0
- code_analysis_client-1.6.58/code_analysis_client/queue_wait.py +256 -0
- code_analysis_client-1.6.58/code_analysis_client/version.txt +1 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/PKG-INFO +37 -3
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/SOURCES.txt +1 -0
- code_analysis_client-1.6.56/code_analysis_client/exceptions.py +0 -21
- code_analysis_client-1.6.56/code_analysis_client/version.txt +0 -1
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/commands_proxy.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/config.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/file_session.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/py.typed +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/responses.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/server_api.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/server_schema.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/universal_file.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/validation.py +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/dependency_links.txt +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/requires.txt +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/top_level.txt +0 -0
- {code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/pyproject.toml +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/__init__.py
RENAMED
|
@@ -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
|
|
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,
|
|
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
|
|
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.
|
|
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[
|
|
225
|
+
status_hook: Optional[StatusHook] = None,
|
|
175
226
|
) -> Dict[str, Any]:
|
|
176
|
-
"""
|
|
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.
|
|
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
|
|
200
|
-
return await self.
|
|
257
|
+
"""Run any registered server command; queued jobs are polled to completion."""
|
|
258
|
+
return await self._execute(
|
|
201
259
|
command,
|
|
202
|
-
params
|
|
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[
|
|
277
|
+
status_hook: Optional[StatusHook] = None,
|
|
217
278
|
) -> Dict[str, Any]:
|
|
218
|
-
"""
|
|
219
|
-
|
|
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
|
|
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}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.6.58
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-analysis-client
|
|
3
|
-
Version: 1.6.
|
|
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
|
|
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
|
-
|
|
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 {}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
1.6.56
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/commands_proxy.py
RENAMED
|
File without changes
|
|
File without changes
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/file_session.py
RENAMED
|
File without changes
|
|
File without changes
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/responses.py
RENAMED
|
File without changes
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/server_api.py
RENAMED
|
File without changes
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/server_schema.py
RENAMED
|
File without changes
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/universal_file.py
RENAMED
|
File without changes
|
{code_analysis_client-1.6.56 → code_analysis_client-1.6.58}/code_analysis_client/validation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|