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,63 @@
1
+ """
2
+ Dynamic command wrappers: schema from server ``help``, then local shallow validation.
3
+
4
+ Author: Vasiliy Zdanovskiy
5
+ email: vasilyvz@gmail.com
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any, Dict, Optional, cast
11
+
12
+ if TYPE_CHECKING:
13
+ from code_analysis_client.client import CodeAnalysisAsyncClient
14
+
15
+
16
+ class ValidatedCommandsProxy:
17
+ """``client.commands.<name>(**params)`` — fetch schema via ``help``, validate, ``execute_command``."""
18
+
19
+ __slots__ = ("_client",)
20
+
21
+ def __init__(self, client: CodeAnalysisAsyncClient) -> None:
22
+ """Store the client used for schema lookup and validated command calls."""
23
+ object.__setattr__(self, "_client", client)
24
+
25
+ def clear_schema_cache(self) -> None:
26
+ """Forget cached schemas (e.g. after server ``reload``)."""
27
+ self._client.clear_command_schema_cache()
28
+
29
+ async def fetch_schema(
30
+ self, command: str, *, refresh: bool = False
31
+ ) -> Dict[str, Any]:
32
+ """Return input JSON schema for ``command`` (from server ``help``)."""
33
+ return cast(
34
+ Dict[str, Any],
35
+ await self._client.get_command_schema(command, refresh=refresh),
36
+ )
37
+
38
+ async def invoke(
39
+ self,
40
+ command: str,
41
+ params: Optional[Dict[str, Any]] = None,
42
+ **kwargs: Any,
43
+ ) -> Dict[str, Any]:
44
+ """Merge ``params`` and ``kwargs``, validate against server schema, execute."""
45
+ merged: Dict[str, Any] = dict(params or {})
46
+ if kwargs:
47
+ merged.update(kwargs)
48
+ return cast(Dict[str, Any], await self._client.call_validated(command, merged))
49
+
50
+ def __getattr__(self, name: str) -> Any:
51
+ """Return an async validated-call wrapper for an arbitrary command name."""
52
+ if name.startswith("_"):
53
+ raise AttributeError(name)
54
+
55
+ async def _bound(**kw: Any) -> Dict[str, Any]:
56
+ """Execute the dynamically selected command with validated keyword params."""
57
+ return cast(Dict[str, Any], await self._client.call_validated(name, kw))
58
+
59
+ _bound.__name__ = name
60
+ _bound.__doc__ = (
61
+ f"Validated call for command {name!r} (schema from server help)."
62
+ )
63
+ return _bound
@@ -0,0 +1,198 @@
1
+ """
2
+ Map server / adapter-style settings dicts to JsonRpcClient constructor kwargs.
3
+
4
+ Author: Vasiliy Zdanovskiy
5
+ email: vasilyvz@gmail.com
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any, Mapping
15
+
16
+ CANONICAL_CONFIG_PATH = Path("/etc/casmgr/config.json")
17
+
18
+ #: Explicit adapter wait/timeout configuration of the client (owner decision 2026-10-24, item M5).
19
+ #: The client hands it to ``JsonRpcClient(ws_config=...)``; the adapter resolves every wait timeout
20
+ #: per call -> this config -> its own constants (60 s ``job_wait_timeout`` and ``cancel`` policy),
21
+ #: so nothing here is left to the adapter's defaults. ``job_wait_timeout`` 0 means no total bound
22
+ #: (a long queued command is never cut off); ``on_timeout`` ``detach`` means a caller timeout
23
+ #: leaves the job running. ``job_push`` mirrors the server section of the same name.
24
+ #: Keep equal to ``code_analysis.core.adapter_timeout_config`` (the server copy, same values).
25
+ DEFAULT_CLIENT_ADAPTER_CONFIG: dict[str, dict[str, Any]] = {
26
+ "websocket": {
27
+ "job_wait_timeout": 0,
28
+ "on_timeout": "detach",
29
+ "receive_timeout": 60,
30
+ "receive_timeout_buffer": 5,
31
+ "heartbeat_interval": 30,
32
+ "terminal_poll_interval": 2,
33
+ "close_timeout": 5,
34
+ "subscribe_settle_delay": 0.5,
35
+ "cancel_grace_seconds": 0.5,
36
+ },
37
+ "job_push": {
38
+ "on_caller_loss": "detach",
39
+ "poll_interval": 0.1,
40
+ },
41
+ }
42
+
43
+
44
+ def merged_client_ws_config(user_config: Any = None) -> dict[str, Any]:
45
+ """Return ``ws_config`` for ``JsonRpcClient``: the defaults with ``user_config`` merged over them.
46
+
47
+ ``user_config`` is a mapping (or ``None``); each section is merged key by key so a partial
48
+ override keeps every other explicit default. Sections other than the defaults' are kept as given.
49
+ """
50
+ merged: dict[str, Any] = copy.deepcopy(DEFAULT_CLIENT_ADAPTER_CONFIG)
51
+ if user_config is None:
52
+ return merged
53
+ raw = getattr(user_config, "raw_content", None) if not isinstance(user_config, Mapping) else user_config
54
+ if not isinstance(raw, Mapping):
55
+ return merged
56
+ for name, value in raw.items():
57
+ if isinstance(value, Mapping) and isinstance(merged.get(name), dict):
58
+ merged[name].update(value)
59
+ else:
60
+ merged[name] = copy.deepcopy(value)
61
+ return merged
62
+
63
+
64
+ def resolve_config_path(explicit: str | Path | None = None) -> Path:
65
+ """Return the server config path: explicit, then ``CASMGR_CONFIG``, then ``/etc/casmgr/config.json``.
66
+
67
+ Never falls back to the current working directory.
68
+
69
+ Raises:
70
+ FileNotFoundError: when no explicit path, no ``CASMGR_CONFIG`` and no ``/etc/casmgr/config.json``.
71
+ """
72
+ if explicit is not None and str(explicit).strip():
73
+ return Path(str(explicit)).expanduser().resolve()
74
+ env_value = os.environ.get("CASMGR_CONFIG", "").strip()
75
+ if env_value:
76
+ return Path(env_value).expanduser().resolve()
77
+ if CANONICAL_CONFIG_PATH.is_file():
78
+ return CANONICAL_CONFIG_PATH
79
+ raise FileNotFoundError(
80
+ "no server config found: pass a path, set CASMGR_CONFIG, or install /etc/casmgr/config.json"
81
+ )
82
+
83
+
84
+ def _expand_path(value: Any) -> str | None:
85
+ """Expand a non-empty filesystem path value to an absolute string."""
86
+ if value is None or value == "":
87
+ return None
88
+ return str(Path(str(value)).expanduser().resolve())
89
+
90
+
91
+ def _ssl_paths_from_section(ssl_section: Any) -> dict[str, str]:
92
+ """Extract and normalize TLS path fields from a config SSL section."""
93
+ if not isinstance(ssl_section, dict):
94
+ return {}
95
+ out: dict[str, str] = {}
96
+ for key in ("cert", "key", "ca", "cert_path", "key_path", "ca_path"):
97
+ raw = ssl_section.get(key)
98
+ expanded = _expand_path(raw) if raw else None
99
+ if expanded:
100
+ out[key] = expanded
101
+ # Normalize aliases to cert/key/ca for downstream comparison / JsonRpcClient
102
+ if "cert" not in out and out.get("cert_path"):
103
+ out["cert"] = out["cert_path"]
104
+ if "key" not in out and out.get("key_path"):
105
+ out["key"] = out["key_path"]
106
+ if "ca" not in out and out.get("ca_path"):
107
+ out["ca"] = out["ca_path"]
108
+ return out
109
+
110
+
111
+ def _network_from_server_config(config: Mapping[str, Any]) -> dict[str, Any]:
112
+ """Return host, port, and protocol settings from a server config dict."""
113
+ server = config.get("server", {})
114
+ if not isinstance(server, dict):
115
+ server = {}
116
+ host = server.get("host", "127.0.0.1")
117
+ if host in ("0.0.0.0", "::", "[::]"):
118
+ host = "127.0.0.1"
119
+ return {
120
+ "host": host,
121
+ "port": int(server.get("port", 15001)),
122
+ "protocol": server.get("protocol", "http"),
123
+ }
124
+
125
+
126
+ def _client_mtls_paths_from_server_config(config: Mapping[str, Any]) -> dict[str, str]:
127
+ """Prefer client.ssl; fall back to server.ssl (same rules as pipeline)."""
128
+ client_section = config.get("client", {})
129
+ if isinstance(client_section, dict):
130
+ ssl_section = client_section.get("ssl", {})
131
+ if isinstance(ssl_section, dict) and any(
132
+ ssl_section.get(k) for k in ("cert", "cert_path", "key", "key_path")
133
+ ):
134
+ return _ssl_paths_from_section(ssl_section)
135
+ server = config.get("server", {})
136
+ if not isinstance(server, dict):
137
+ return {}
138
+ return _ssl_paths_from_section(server.get("ssl", {}))
139
+
140
+
141
+ def adapter_settings_from_server_config(config: Mapping[str, Any]) -> dict[str, Any]:
142
+ """Build adapter-style settings from a code-analysis server config dict."""
143
+ settings = dict(_network_from_server_config(config))
144
+ proto = str(settings.get("protocol", "")).lower()
145
+ if proto == "mtls":
146
+ settings["ssl"] = _client_mtls_paths_from_server_config(config)
147
+ elif proto == "https":
148
+ ssl_paths = _client_mtls_paths_from_server_config(config)
149
+ if ssl_paths.get("cert") and ssl_paths.get("key"):
150
+ settings["ssl"] = ssl_paths
151
+ return settings
152
+
153
+
154
+ def adapter_settings_to_jsonrpc_kwargs(
155
+ settings: Mapping[str, Any],
156
+ *,
157
+ timeout: float | None = 60.0,
158
+ check_hostname: bool = False,
159
+ token_header: str | None = None,
160
+ token: str | None = None,
161
+ ) -> dict[str, Any]:
162
+ """Turn adapter-style settings into keyword args for JsonRpcClient."""
163
+ ssl_section = settings.get("ssl")
164
+ cert = key = ca = None
165
+ if isinstance(ssl_section, dict):
166
+ cert = ssl_section.get("cert") or ssl_section.get("cert_path")
167
+ key = ssl_section.get("key") or ssl_section.get("key_path")
168
+ ca = ssl_section.get("ca") or ssl_section.get("ca_path")
169
+ cert = _expand_path(cert) if cert else None
170
+ key = _expand_path(key) if key else None
171
+ ca = _expand_path(ca) if ca else None
172
+
173
+ th = token_header if token_header is not None else settings.get("token_header")
174
+ tok = token if token is not None else settings.get("token")
175
+
176
+ return {
177
+ "protocol": str(settings.get("protocol", "http")),
178
+ "host": str(settings.get("host", "127.0.0.1")),
179
+ "port": int(settings.get("port", 8080)),
180
+ "token_header": str(th) if th else None,
181
+ "token": str(tok) if tok else None,
182
+ "cert": cert,
183
+ "key": key,
184
+ "ca": ca,
185
+ "check_hostname": check_hostname,
186
+ "timeout": timeout,
187
+ }
188
+
189
+
190
+ def load_server_config(path: str | Path) -> dict[str, Any]:
191
+ """Load server config.json into a dict."""
192
+ p = Path(path).expanduser()
193
+ with p.open("r", encoding="utf-8") as f:
194
+ data: Any = json.load(f)
195
+ if not isinstance(data, dict):
196
+ raise ValueError("Server config must be a JSON object")
197
+ # py3.10: return plain dict for mutability
198
+ return dict(data)
@@ -0,0 +1,188 @@
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, or the server refused a command.
17
+
18
+ ``code`` is ``VALIDATION_ERROR`` for a client-side parameter failure; when the server
19
+ answered with a refusal, ``code`` is the exact server error code (for example
20
+ ``SESSION_OPEN_IN_EDITOR``) and ``details`` is the server's error details object.
21
+ """
22
+
23
+ code: str | int = "VALIDATION_ERROR"
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ *,
29
+ field: Optional[str] = None,
30
+ details: Optional[Dict[str, Any]] = None,
31
+ code: str | int | None = None,
32
+ ) -> None:
33
+ """Initialize with optional field, details payload and the server error code."""
34
+ super().__init__(message)
35
+ self.field = field
36
+ self.details = details or {}
37
+ if code:
38
+ self.code = code
39
+
40
+
41
+ class QueueJobError(RuntimeError):
42
+ """Base for queued-job runtime errors (job failure, timeout, command failure).
43
+
44
+ Deliberately does **not** inherit :class:`ClientValidationError`. That class
45
+ represents bad input caught before a call is even sent, and an
46
+ ``except ClientValidationError:`` handler written for that purpose must not
47
+ silently swallow a runtime failure that happened server-side after a valid
48
+ call was queued and polled. Catch ``QueueJobError`` (or its subclasses)
49
+ separately from parameter-validation errors.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ *,
56
+ job_id: Optional[str] = None,
57
+ details: Optional[Dict[str, Any]] = None,
58
+ ) -> None:
59
+ """Initialize with message, the job id involved, and a details payload."""
60
+ super().__init__(message)
61
+ self.job_id = job_id
62
+ self.details = details or {}
63
+
64
+
65
+ class JobFailedError(QueueJobError):
66
+ """A queued job was stopped or cancelled, or reported a queue-level error status.
67
+
68
+ A queued job that ended ``failed`` with the command's own error raises
69
+ :class:`CommandFailedError` instead (owner decision 2026-11-07, bug e58c5982).
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ job_id: Optional[str],
75
+ error: Any = None,
76
+ *,
77
+ status: Optional[str] = None,
78
+ ) -> None:
79
+ """Store job id, verbatim error payload, and terminal status."""
80
+ message = f"Job {job_id!r} failed (status={status!r}): {error!r}"
81
+ super().__init__(
82
+ message,
83
+ job_id=job_id,
84
+ details={"job_id": job_id, "status": status, "error": error},
85
+ )
86
+ self.error = error
87
+ self.status = status
88
+
89
+
90
+ class JobTimeoutError(QueueJobError):
91
+ """The caller wait exceeded ``timeout``; the call detached and the job keeps running server-side."""
92
+
93
+ def __init__(self, job_id: Optional[str], timeout: Optional[float]) -> None:
94
+ """Store job id and the timeout that was exceeded."""
95
+ message = (
96
+ f"Timed out after {timeout}s waiting for job {job_id!r}; "
97
+ "the job keeps running server-side - wait for it again with "
98
+ "QueuedJob.wait or retry with a longer timeout."
99
+ )
100
+ super().__init__(
101
+ message,
102
+ job_id=job_id,
103
+ details={"job_id": job_id, "timeout": timeout},
104
+ )
105
+ self.timeout = timeout
106
+
107
+
108
+ class CommandFailedError(QueueJobError):
109
+ """A command ran but its inner result is an error envelope (``success`` false).
110
+
111
+ Raised for immediate and queued commands alike; a failing inner result is never
112
+ returned as a plain dict.
113
+ """
114
+
115
+ def __init__(
116
+ self,
117
+ command: Optional[str],
118
+ job_id: Optional[str],
119
+ error: Any,
120
+ *,
121
+ result: Optional[Dict[str, Any]] = None,
122
+ ) -> None:
123
+ """Store command name, job id, the verbatim inner error object and the inner envelope."""
124
+ message = f"Command {command!r} failed (job_id={job_id!r}): {error!r}"
125
+ super().__init__(
126
+ message,
127
+ job_id=job_id,
128
+ details={"command": command, "job_id": job_id, "error": error},
129
+ )
130
+ self.command = command
131
+ self.error = error
132
+ self.result = result
133
+
134
+
135
+ class VectorizerUnavailableError(RuntimeError):
136
+ """The vectorizer (embedding or chunker service) is not available.
137
+
138
+ Raised by vectorizer-dependent commands (semantic_search, revectorize)
139
+ when the embedding or chunker service is known unavailable.
140
+ The pre-V1 contract EMBEDDING_SERVICE_ERROR is reserved for a failure of
141
+ the call itself, not for unavailability of a known-down service.
142
+ """
143
+
144
+ code = "VECTORIZER_UNAVAILABLE"
145
+
146
+ def __init__(
147
+ self,
148
+ message: str,
149
+ *,
150
+ service: Optional[str] = None,
151
+ services: Optional[list[str]] = None,
152
+ reason: Optional[str] = None,
153
+ since: Optional[str] = None,
154
+ last_probe_at: Optional[str] = None,
155
+ retry_after_seconds: Optional[int] = None,
156
+ details: Optional[Dict[str, Any]] = None,
157
+ ) -> None:
158
+ """Initialize with message and vectorizer availability details.
159
+
160
+ Args:
161
+ message: Human-readable error message
162
+ service: The first unavailable required service (embedding or chunker)
163
+ services: List of all unavailable required services
164
+ reason: Reason for unavailability (e.g., timeout, connection_refused)
165
+ since: ISO-8601 UTC timestamp of when the service became unavailable
166
+ last_probe_at: ISO-8601 UTC timestamp of the last probe attempt
167
+ retry_after_seconds: Suggested wait time before retrying
168
+ details: Additional details dict (overrides individual fields if provided)
169
+ """
170
+ super().__init__(message)
171
+ if details is None:
172
+ details = {}
173
+ self.service = service or details.get("service")
174
+ self.services = services or details.get("services", [])
175
+ self.reason = reason or details.get("reason")
176
+ self.since = since or details.get("since")
177
+ self.last_probe_at = last_probe_at or details.get("last_probe_at")
178
+ self.retry_after_seconds = retry_after_seconds or details.get(
179
+ "retry_after_seconds"
180
+ )
181
+ self.details = {
182
+ "service": self.service,
183
+ "services": self.services,
184
+ "reason": self.reason,
185
+ "since": self.since,
186
+ "last_probe_at": self.last_probe_at,
187
+ "retry_after_seconds": self.retry_after_seconds,
188
+ }