quantikmind-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Quantik Mind CLI package."""
2
+
3
+ from importlib import metadata as importlib_metadata
4
+
5
+
6
+ __version__ = importlib_metadata.version("quantikmind-cli")
@@ -0,0 +1 @@
1
+ """Local agent foundations for qmind."""
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Mapping
5
+
6
+ import requests
7
+
8
+
9
+ class PrometheusError(RuntimeError):
10
+ """Raised when Prometheus cannot be queried or returns an invalid response."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MetricCheck:
15
+ name: str
16
+ found: bool
17
+ warning: str | None = None
18
+
19
+
20
+ SERVICE_METRIC_QUERIES = {
21
+ "cpu_load": "avg by (service)(service_cpu_load)",
22
+ "error_rate": "rate(service_errors_total[5m])",
23
+ "request_rate": "rate(service_requests_total[5m])",
24
+ "latency_p95": "histogram_quantile(0.95, rate(service_response_time_seconds_bucket[5m]))",
25
+ }
26
+
27
+
28
+ class PrometheusClient:
29
+ def __init__(
30
+ self,
31
+ base_url: str,
32
+ *,
33
+ timeout: float = 10.0,
34
+ session: requests.Session | None = None,
35
+ ) -> None:
36
+ self.base_url = normalize_prometheus_url(base_url)
37
+ self.timeout = timeout
38
+ self.session = session or requests.Session()
39
+
40
+ def query(self, query: str) -> list[Mapping[str, Any]]:
41
+ url = f"{self.base_url}/api/v1/query"
42
+ try:
43
+ response = self.session.get(url, params={"query": query}, timeout=self.timeout)
44
+ except requests.RequestException as exc:
45
+ raise PrometheusError(f"Unable to reach Prometheus at {self.base_url}: {exc}") from exc
46
+
47
+ if response.status_code >= 400:
48
+ raise PrometheusError(f"Prometheus query failed with HTTP {response.status_code}")
49
+
50
+ try:
51
+ payload = response.json()
52
+ except ValueError as exc:
53
+ raise PrometheusError("Prometheus returned a non-JSON response") from exc
54
+
55
+ if not isinstance(payload, dict) or payload.get("status") != "success":
56
+ raise PrometheusError("Prometheus returned an unsuccessful or malformed response")
57
+
58
+ data = payload.get("data")
59
+ if not isinstance(data, dict):
60
+ raise PrometheusError("Prometheus response is missing data")
61
+
62
+ result = data.get("result")
63
+ if not isinstance(result, list):
64
+ raise PrometheusError("Prometheus response is missing result data")
65
+
66
+ return result
67
+
68
+ def check_connectivity(self) -> None:
69
+ self.query("up")
70
+
71
+ def check_metric(self, metric_name: str) -> MetricCheck:
72
+ try:
73
+ result = self.query(metric_name)
74
+ except PrometheusError as exc:
75
+ return MetricCheck(metric_name, found=False, warning=str(exc))
76
+ return MetricCheck(metric_name, found=bool(result))
77
+
78
+ def collect_service_metrics(self) -> dict[str, dict[str, float]]:
79
+ metrics: dict[str, dict[str, float]] = {}
80
+ for payload_key, query in SERVICE_METRIC_QUERIES.items():
81
+ for sample in self.query(query):
82
+ service = _sample_service(sample)
83
+ if not service:
84
+ continue
85
+ value = _sample_value(sample)
86
+ if value is None:
87
+ continue
88
+ metrics.setdefault(service, {})[payload_key] = value
89
+ return metrics
90
+
91
+
92
+ def normalize_prometheus_url(value: str) -> str:
93
+ normalized = value.strip().rstrip("/")
94
+ if not normalized:
95
+ raise PrometheusError("Prometheus URL cannot be empty")
96
+ return normalized
97
+
98
+
99
+ def _sample_service(sample: Mapping[str, Any]) -> str | None:
100
+ metric = sample.get("metric")
101
+ if not isinstance(metric, Mapping):
102
+ return None
103
+ service = metric.get("service")
104
+ if service is None:
105
+ return None
106
+ normalized = str(service).strip()
107
+ return normalized or None
108
+
109
+
110
+ def _sample_value(sample: Mapping[str, Any]) -> float | None:
111
+ value = sample.get("value")
112
+ if not isinstance(value, list) or len(value) < 2:
113
+ return None
114
+ try:
115
+ return float(value[1])
116
+ except (TypeError, ValueError):
117
+ return None
@@ -0,0 +1 @@
1
+ """Agent push placeholder."""
@@ -0,0 +1 @@
1
+ """Agent service placeholder."""
quantikmind_cli/api.py ADDED
@@ -0,0 +1,326 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ import requests
6
+
7
+ from .config import DEFAULT_BASE_URL, resolve_base_url
8
+
9
+
10
+ class ApiError(RuntimeError):
11
+ """Base API client error."""
12
+
13
+
14
+ class AuthenticationError(ApiError):
15
+ """Raised when credentials are missing or rejected."""
16
+
17
+
18
+ class NetworkError(ApiError):
19
+ """Raised when the backend cannot be reached."""
20
+
21
+
22
+ class AgentMetricsEndpointUnavailable(ApiError):
23
+ """Raised when the agent metrics endpoint is not available on the backend."""
24
+
25
+
26
+ class QuantikMindApiClient:
27
+ def __init__(
28
+ self,
29
+ *,
30
+ base_url: str = DEFAULT_BASE_URL,
31
+ api_key: str | None = None,
32
+ token: str | None = None,
33
+ auth_mode: str = "auto",
34
+ timeout: float = 30.0,
35
+ session: requests.Session | None = None,
36
+ ) -> None:
37
+ self.base_url = base_url.rstrip("/")
38
+ self.api_key = api_key
39
+ self.token = token
40
+ self.auth_mode = auth_mode
41
+ self.timeout = timeout
42
+ self.session = session or requests.Session()
43
+
44
+ @classmethod
45
+ def from_config(
46
+ cls,
47
+ repo_config: Mapping[str, Any] | None,
48
+ credentials: Mapping[str, Any] | None,
49
+ ) -> "QuantikMindApiClient":
50
+ credentials = credentials or {}
51
+ return cls(
52
+ base_url=resolve_base_url(repo_config),
53
+ api_key=credentials.get("api_key"),
54
+ token=credentials.get("token") or credentials.get("access_token"),
55
+ auth_mode=str(credentials.get("auth_mode") or "auto"),
56
+ )
57
+
58
+ def auth_headers(self) -> dict[str, str]:
59
+ credential = self.api_key or self.token
60
+ if not credential:
61
+ return {}
62
+ if self.api_key and (
63
+ self.auth_mode in {"api_key", "api-key-header"} or _looks_like_quantikmind_api_key(self.api_key)
64
+ ):
65
+ return {"X-QM-API-Key": self.api_key}
66
+ return {"Authorization": f"Bearer {credential}"}
67
+
68
+ def request(
69
+ self,
70
+ method: str,
71
+ path: str,
72
+ *,
73
+ json: Any | None = None,
74
+ params: Mapping[str, Any] | None = None,
75
+ headers: Mapping[str, str] | None = None,
76
+ ) -> Any:
77
+ url = f"{self.base_url}/{path.lstrip('/')}"
78
+ merged_headers = {"Accept": "application/json"}
79
+ merged_headers.update(self.auth_headers())
80
+ if headers:
81
+ merged_headers.update(headers)
82
+
83
+ try:
84
+ response = self.session.request(
85
+ method=method.upper(),
86
+ url=url,
87
+ json=json,
88
+ params=dict(params or {}),
89
+ headers=merged_headers,
90
+ timeout=self.timeout,
91
+ )
92
+ except requests.RequestException as exc:
93
+ raise NetworkError(f"Unable to reach Quantik Mind API: {exc}") from exc
94
+
95
+ if response.status_code in {401, 403}:
96
+ raise AuthenticationError(_error_message(response, "Authentication failed"))
97
+ if response.status_code >= 400:
98
+ raise ApiError(_error_message(response, f"API request failed with {response.status_code}"))
99
+
100
+ if not response.content:
101
+ return None
102
+
103
+ try:
104
+ return response.json()
105
+ except ValueError as exc:
106
+ raise ApiError("API returned a non-JSON response") from exc
107
+
108
+ def list_projects(self) -> list[dict[str, Any]]:
109
+ payload = self.request("GET", "/api/v1/projects/")
110
+ if not isinstance(payload, list):
111
+ raise ApiError("API returned an unexpected projects response")
112
+ return payload
113
+
114
+ def get_auth_identity(self) -> dict[str, Any]:
115
+ payload = self.request("GET", "/api/v1/auth/me")
116
+ if not isinstance(payload, dict):
117
+ raise ApiError("API returned an unexpected auth identity response")
118
+ return payload
119
+
120
+ def get_project_by_name(self, name: str) -> dict[str, Any] | None:
121
+ normalized_name = name.strip().lower()
122
+ for project in self.list_projects():
123
+ if str(project.get("name", "")).strip().lower() == normalized_name:
124
+ return project
125
+ return None
126
+
127
+ def get_project(self, project_id: str) -> dict[str, Any]:
128
+ payload = self.request("GET", f"/api/v1/projects/{project_id}")
129
+ if not isinstance(payload, dict):
130
+ raise ApiError("API returned an unexpected project response")
131
+ return payload
132
+
133
+ def validate_credentials(self) -> bool:
134
+ try:
135
+ self.get_auth_identity()
136
+ except (AuthenticationError, ApiError):
137
+ self.list_projects()
138
+ return True
139
+
140
+ def create_project(self, name: str) -> dict[str, Any]:
141
+ payload = self.request("POST", "/api/v1/projects/", json={"name": name})
142
+ if not isinstance(payload, dict):
143
+ raise ApiError("API returned an unexpected project creation response")
144
+ return payload
145
+
146
+ def upload_functional_library(
147
+ self,
148
+ project_id: str,
149
+ tests: list[dict[str, Any]],
150
+ ) -> dict[str, Any]:
151
+ payload = self.request(
152
+ "POST",
153
+ "/api/v1/functional/library/upload-library",
154
+ json=tests,
155
+ params={"project_id": project_id},
156
+ )
157
+ if not isinstance(payload, dict):
158
+ raise ApiError("API returned an unexpected library upload response")
159
+ return payload
160
+
161
+ def get_signal_readiness(self, project_id: str) -> dict[str, Any]:
162
+ payload = self.request(
163
+ "GET",
164
+ "/api/v1/functional/signal-readiness",
165
+ params={"project_id": project_id},
166
+ )
167
+ if not isinstance(payload, dict):
168
+ raise ApiError("API returned an unexpected signal-readiness response")
169
+ return payload
170
+
171
+ def get_subscription(self) -> dict[str, Any]:
172
+ payload = self.request("GET", "/api/v1/subscription/me")
173
+ if not isinstance(payload, dict):
174
+ raise ApiError("API returned an unexpected subscription response")
175
+ return payload
176
+
177
+ def select_functional_tests(
178
+ self,
179
+ project_id: str,
180
+ *,
181
+ changed_files: list[str] | None = None,
182
+ ) -> dict[str, Any]:
183
+ params: dict[str, Any] = {"project_id": project_id}
184
+ if changed_files:
185
+ params["changed_files"] = changed_files
186
+ payload = self.request(
187
+ "GET",
188
+ "/api/v1/functional/select",
189
+ params=params,
190
+ )
191
+ if not isinstance(payload, dict):
192
+ raise ApiError("API returned an unexpected functional selection response")
193
+ return payload
194
+
195
+ def record_build_commit(
196
+ self,
197
+ *,
198
+ project_id: str,
199
+ commit_hash: str,
200
+ message: str,
201
+ author: str | None = None,
202
+ branch: str | None = None,
203
+ files_changed: list[str] | None = None,
204
+ ) -> dict[str, Any]:
205
+ payload: dict[str, Any] = {
206
+ "project_id": project_id,
207
+ "commit_hash": commit_hash,
208
+ "message": message,
209
+ }
210
+ if author:
211
+ payload["author"] = author
212
+ if branch:
213
+ payload["branch"] = branch
214
+ if files_changed is not None:
215
+ payload["files_changed"] = files_changed
216
+
217
+ response = self.request(
218
+ "POST",
219
+ "/api/v1/functional/commits/record",
220
+ json=payload,
221
+ )
222
+ if not isinstance(response, dict):
223
+ raise ApiError("API returned an unexpected build recording response")
224
+ return response
225
+
226
+ def import_functional_history(
227
+ self,
228
+ *,
229
+ project_id: str,
230
+ records: list[dict[str, Any]],
231
+ ) -> dict[str, Any]:
232
+ response = self.request(
233
+ "POST",
234
+ "/api/v1/functional/history/import",
235
+ json={
236
+ "project_id": project_id,
237
+ "records": records,
238
+ },
239
+ )
240
+ if not isinstance(response, dict):
241
+ raise ApiError("API returned an unexpected history import response")
242
+ return response
243
+
244
+ def push_agent_metrics(self, payload: Mapping[str, Any]) -> dict[str, Any]:
245
+ try:
246
+ response = self.request("POST", "/api/v1/agent/metrics", json=dict(payload))
247
+ except ApiError as exc:
248
+ if "404" in str(exc):
249
+ raise AgentMetricsEndpointUnavailable(str(exc)) from exc
250
+ raise
251
+
252
+ if response is None:
253
+ return {}
254
+ if not isinstance(response, dict):
255
+ raise ApiError("API returned an unexpected agent metrics response")
256
+ return response
257
+
258
+ def configure_prometheus_observability(self, payload: Mapping[str, Any]) -> dict[str, Any]:
259
+ response = self.request(
260
+ "PUT",
261
+ "/api/v1/observability/prometheus",
262
+ json=dict(payload),
263
+ )
264
+ if not isinstance(response, dict):
265
+ raise ApiError("API returned an unexpected observability configuration response")
266
+ return response
267
+
268
+ def get_observability_status(self, project_id: str) -> dict[str, Any]:
269
+ response = self.request(
270
+ "GET",
271
+ "/api/v1/observability/status",
272
+ params={"project_id": project_id},
273
+ )
274
+ if not isinstance(response, dict):
275
+ raise ApiError("API returned an unexpected observability status response")
276
+ return response
277
+
278
+
279
+ def _error_message(response: requests.Response, fallback: str) -> str:
280
+ try:
281
+ payload = response.json()
282
+ except ValueError:
283
+ text = response.text.strip()
284
+ return f"{fallback}: {text}" if text else fallback
285
+
286
+ detail = payload.get("detail") if isinstance(payload, dict) else None
287
+ if isinstance(detail, dict):
288
+ message = str(detail.get("detail") or detail)
289
+ errors = detail.get("errors")
290
+ if isinstance(errors, list) and errors:
291
+ rendered_errors = []
292
+ for item in errors:
293
+ if isinstance(item, dict):
294
+ prefix_parts = [
295
+ str(item[key])
296
+ for key in ("file", "test_id", "index")
297
+ if item.get(key) is not None
298
+ ]
299
+ prefix = ": ".join(prefix_parts)
300
+ error_text = str(item.get("error") or item)
301
+ rendered_errors.append(f"- {prefix}: {error_text}" if prefix else f"- {error_text}")
302
+ else:
303
+ rendered_errors.append(f"- {item}")
304
+ message = message + "\n" + "\n".join(rendered_errors)
305
+ detections = detail.get("detections")
306
+ if isinstance(detections, list) and detections:
307
+ rendered_detections = []
308
+ for item in detections:
309
+ if not isinstance(item, dict):
310
+ continue
311
+ preset = item.get("preset") or "unknown"
312
+ detected = ", ".join(item.get("detected_signals") or []) or "none"
313
+ missing = ", ".join(item.get("missing_signals") or []) or "none"
314
+ rendered_detections.append(
315
+ f"- {preset}: detected={detected}; missing={missing}"
316
+ )
317
+ if rendered_detections:
318
+ message = message + "\n" + "\n".join(rendered_detections)
319
+ return f"{fallback}: {message}"
320
+ if detail:
321
+ return f"{fallback}: {detail}"
322
+ return fallback
323
+
324
+
325
+ def _looks_like_quantikmind_api_key(value: str) -> bool:
326
+ return value.startswith(("qm_dev_", "qm_live_"))