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.
- quantikmind_cli/__init__.py +6 -0
- quantikmind_cli/agent/__init__.py +1 -0
- quantikmind_cli/agent/prometheus.py +117 -0
- quantikmind_cli/agent/push.py +1 -0
- quantikmind_cli/agent/service.py +1 -0
- quantikmind_cli/api.py +326 -0
- quantikmind_cli/cli.py +1672 -0
- quantikmind_cli/config.py +111 -0
- quantikmind_cli/git.py +83 -0
- quantikmind_cli/manifest.py +134 -0
- quantikmind_cli/record_tests.py +268 -0
- quantikmind_cli/scanner.py +547 -0
- quantikmind_cli/subset.py +106 -0
- quantikmind_cli-0.1.0.dist-info/METADATA +695 -0
- quantikmind_cli-0.1.0.dist-info/RECORD +18 -0
- quantikmind_cli-0.1.0.dist-info/WHEEL +5 -0
- quantikmind_cli-0.1.0.dist-info/entry_points.txt +2 -0
- quantikmind_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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_"))
|