quantikmind-cli 0.1.0__tar.gz → 1.0.0__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.
- quantikmind_cli-1.0.0/PKG-INFO +30 -0
- quantikmind_cli-1.0.0/README.md +5 -0
- quantikmind_cli-1.0.0/pyproject.toml +46 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/prometheus.py +29 -5
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/api.py +47 -5
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/cli.py +77 -6
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/config.py +4 -0
- quantikmind_cli-1.0.0/quantikmind_cli/influence.py +253 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/record_tests.py +25 -0
- quantikmind_cli-1.0.0/quantikmind_cli/runs.py +843 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/scanner.py +1 -1
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/subset.py +14 -0
- quantikmind_cli-1.0.0/quantikmind_cli/telemetry.py +23 -0
- quantikmind_cli-1.0.0/quantikmind_cli.egg-info/PKG-INFO +30 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/SOURCES.txt +8 -1
- quantikmind_cli-1.0.0/quantikmind_cli.egg-info/requires.txt +7 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_api.py +53 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_cli.py +121 -5
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_config.py +11 -0
- quantikmind_cli-1.0.0/tests/test_doc_templates.py +30 -0
- quantikmind_cli-1.0.0/tests/test_influence.py +107 -0
- quantikmind_cli-1.0.0/tests/test_prometheus.py +202 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_record_tests.py +18 -0
- quantikmind_cli-1.0.0/tests/test_saved_runs.py +437 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_scanner.py +12 -0
- quantikmind_cli-1.0.0/tests/test_telemetry.py +20 -0
- quantikmind_cli-0.1.0/PKG-INFO +0 -695
- quantikmind_cli-0.1.0/README.md +0 -663
- quantikmind_cli-0.1.0/pyproject.toml +0 -69
- quantikmind_cli-0.1.0/quantikmind_cli.egg-info/PKG-INFO +0 -695
- quantikmind_cli-0.1.0/quantikmind_cli.egg-info/requires.txt +0 -7
- quantikmind_cli-0.1.0/tests/test_prometheus.py +0 -117
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/__init__.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/__init__.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/push.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/agent/service.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/git.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli/manifest.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/dependency_links.txt +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/entry_points.txt +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/quantikmind_cli.egg-info/top_level.txt +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/setup.cfg +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_e2e_smoke.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_git.py +0 -0
- {quantikmind_cli-0.1.0 → quantikmind_cli-1.0.0}/tests/test_manifest.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quantikmind-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Command line tools for Quantik Mind test selection and runtime signal collection
|
|
5
|
+
Author: Quantik Mind
|
|
6
|
+
Keywords: quantik-mind,testing,ci,cli
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: click
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: PyYAML
|
|
22
|
+
Requires-Dist: rich
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest; extra == "test"
|
|
25
|
+
|
|
26
|
+
# Quantik Mind CLI
|
|
27
|
+
|
|
28
|
+
The canonical installation, command and CI/CD guide is [the integration CLI guide](../docs/integration/03-cli-guide.md). It describes the intended final 1.0.0 release. This source prepares 1.0.0rc1 for local release verification; it is not a published final release. Publication and backend compatibility approval remain separate release gates.
|
|
29
|
+
|
|
30
|
+
For local development: `python -m pip install -e .` from this directory.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Quantik Mind CLI
|
|
2
|
+
|
|
3
|
+
The canonical installation, command and CI/CD guide is [the integration CLI guide](../docs/integration/03-cli-guide.md). It describes the intended final 1.0.0 release. This source prepares 1.0.0rc1 for local release verification; it is not a published final release. Publication and backend compatibility approval remain separate release gates.
|
|
4
|
+
|
|
5
|
+
For local development: `python -m pip install -e .` from this directory.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools==80.9.0", "wheel==0.45.1"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quantikmind-cli"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Command line tools for Quantik Mind test selection and runtime signal collection"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Quantik Mind" },
|
|
13
|
+
]
|
|
14
|
+
keywords = ["quantik-mind", "testing", "ci", "cli"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Software Development :: Testing",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"click",
|
|
29
|
+
"requests",
|
|
30
|
+
"PyYAML",
|
|
31
|
+
"rich",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
test = [
|
|
36
|
+
"pytest",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[project.scripts]
|
|
40
|
+
qmind = "quantikmind_cli.cli:cli"
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.packages.find]
|
|
43
|
+
include = ["quantikmind_cli*"]
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
testpaths = ["tests"]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import math
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
from typing import Any, Mapping
|
|
5
6
|
|
|
@@ -17,11 +18,22 @@ class MetricCheck:
|
|
|
17
18
|
warning: str | None = None
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
# v2 histogram is service_response_time_seconds_bucket: p95 is seconds.
|
|
22
|
+
AGENT_METRICS_CONTRACT_VERSION = "service-metrics-v2"
|
|
23
|
+
|
|
24
|
+
# Both counters must describe the same eligible request population. Missing
|
|
25
|
+
# numerator series remain absent; filtering the denominator excludes no traffic.
|
|
20
26
|
SERVICE_METRIC_QUERIES = {
|
|
21
27
|
"cpu_load": "avg by (service)(service_cpu_load)",
|
|
22
|
-
"error_rate":
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
"error_rate": (
|
|
29
|
+
"sum by (service) (rate(service_errors_total[5m]))"
|
|
30
|
+
" / (sum by (service) (rate(service_requests_total[5m])) > 0)"
|
|
31
|
+
),
|
|
32
|
+
"request_rate": "sum by (service) (rate(service_requests_total[5m]))",
|
|
33
|
+
"latency_p95": (
|
|
34
|
+
"histogram_quantile(0.95, sum by (le, service) "
|
|
35
|
+
"(rate(service_response_time_seconds_bucket[5m])))"
|
|
36
|
+
),
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
|
|
@@ -78,15 +90,26 @@ class PrometheusClient:
|
|
|
78
90
|
def collect_service_metrics(self) -> dict[str, dict[str, float]]:
|
|
79
91
|
metrics: dict[str, dict[str, float]] = {}
|
|
80
92
|
for payload_key, query in SERVICE_METRIC_QUERIES.items():
|
|
93
|
+
seen: set[str] = set()
|
|
81
94
|
for sample in self.query(query):
|
|
82
95
|
service = _sample_service(sample)
|
|
83
96
|
if not service:
|
|
84
97
|
continue
|
|
98
|
+
if service in seen:
|
|
99
|
+
raise PrometheusError(f"Duplicate service result for {payload_key}: {service}")
|
|
100
|
+
seen.add(service)
|
|
85
101
|
value = _sample_value(sample)
|
|
86
102
|
if value is None:
|
|
87
103
|
continue
|
|
104
|
+
if payload_key == "error_rate" and not 0 <= value <= 1:
|
|
105
|
+
continue
|
|
106
|
+
if payload_key in {"request_rate", "latency_p95"} and value < 0:
|
|
107
|
+
continue
|
|
88
108
|
metrics.setdefault(service, {})[payload_key] = value
|
|
89
|
-
|
|
109
|
+
for values in metrics.values():
|
|
110
|
+
if values.get("request_rate", 0) <= 0:
|
|
111
|
+
values.pop("error_rate", None)
|
|
112
|
+
return {service: values for service, values in metrics.items() if values}
|
|
90
113
|
|
|
91
114
|
|
|
92
115
|
def normalize_prometheus_url(value: str) -> str:
|
|
@@ -112,6 +135,7 @@ def _sample_value(sample: Mapping[str, Any]) -> float | None:
|
|
|
112
135
|
if not isinstance(value, list) or len(value) < 2:
|
|
113
136
|
return None
|
|
114
137
|
try:
|
|
115
|
-
|
|
138
|
+
numeric = float(value[1])
|
|
139
|
+
return numeric if math.isfinite(numeric) else None
|
|
116
140
|
except (TypeError, ValueError):
|
|
117
141
|
return None
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from typing import Any, Mapping
|
|
4
|
+
import os
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
4
7
|
|
|
5
8
|
import requests
|
|
6
9
|
|
|
7
|
-
from .config import DEFAULT_BASE_URL, resolve_base_url
|
|
10
|
+
from .config import ConfigError, DEFAULT_BASE_URL, resolve_base_url
|
|
8
11
|
|
|
9
12
|
|
|
10
13
|
class ApiError(RuntimeError):
|
|
@@ -32,6 +35,7 @@ class QuantikMindApiClient:
|
|
|
32
35
|
token: str | None = None,
|
|
33
36
|
auth_mode: str = "auto",
|
|
34
37
|
timeout: float = 30.0,
|
|
38
|
+
selection_read_timeout_seconds: float = 120.0,
|
|
35
39
|
session: requests.Session | None = None,
|
|
36
40
|
) -> None:
|
|
37
41
|
self.base_url = base_url.rstrip("/")
|
|
@@ -39,6 +43,13 @@ class QuantikMindApiClient:
|
|
|
39
43
|
self.token = token
|
|
40
44
|
self.auth_mode = auth_mode
|
|
41
45
|
self.timeout = timeout
|
|
46
|
+
try:
|
|
47
|
+
reference = float(selection_read_timeout_seconds)
|
|
48
|
+
except (TypeError, ValueError) as exc:
|
|
49
|
+
raise ConfigError("selection_read_timeout_seconds must be positive and finite") from exc
|
|
50
|
+
if isinstance(selection_read_timeout_seconds, bool) or not math.isfinite(reference) or reference <= 0:
|
|
51
|
+
raise ConfigError("selection_read_timeout_seconds must be positive and finite")
|
|
52
|
+
self.selection_read_timeout_seconds = reference
|
|
42
53
|
self.session = session or requests.Session()
|
|
43
54
|
|
|
44
55
|
@classmethod
|
|
@@ -53,6 +64,7 @@ class QuantikMindApiClient:
|
|
|
53
64
|
api_key=credentials.get("api_key"),
|
|
54
65
|
token=credentials.get("token") or credentials.get("access_token"),
|
|
55
66
|
auth_mode=str(credentials.get("auth_mode") or "auto"),
|
|
67
|
+
selection_read_timeout_seconds=(repo_config or {}).get("selection_read_timeout_seconds", 120.0),
|
|
56
68
|
)
|
|
57
69
|
|
|
58
70
|
def auth_headers(self) -> dict[str, str]:
|
|
@@ -65,6 +77,16 @@ class QuantikMindApiClient:
|
|
|
65
77
|
return {"X-QM-API-Key": self.api_key}
|
|
66
78
|
return {"Authorization": f"Bearer {credential}"}
|
|
67
79
|
|
|
80
|
+
def safe_error(self, message: str) -> str:
|
|
81
|
+
for value in (self.api_key, self.token):
|
|
82
|
+
if value:
|
|
83
|
+
message = message.replace(value, "[REDACTED]")
|
|
84
|
+
message = re.sub(r"https?://\S+", "[URL omitted]", message)
|
|
85
|
+
message = re.sub(r"qm_(?:live|dev)_[A-Za-z0-9_-]+", "[REDACTED]", message)
|
|
86
|
+
if re.search(r"<\s*(?:html|!doctype)", message, re.I):
|
|
87
|
+
return "API returned an error page; raw response omitted."
|
|
88
|
+
return message[:2000]
|
|
89
|
+
|
|
68
90
|
def request(
|
|
69
91
|
self,
|
|
70
92
|
method: str,
|
|
@@ -73,6 +95,7 @@ class QuantikMindApiClient:
|
|
|
73
95
|
json: Any | None = None,
|
|
74
96
|
params: Mapping[str, Any] | None = None,
|
|
75
97
|
headers: Mapping[str, str] | None = None,
|
|
98
|
+
timeout: float | tuple[float, float] | None = None,
|
|
76
99
|
) -> Any:
|
|
77
100
|
url = f"{self.base_url}/{path.lstrip('/')}"
|
|
78
101
|
merged_headers = {"Accept": "application/json"}
|
|
@@ -87,15 +110,22 @@ class QuantikMindApiClient:
|
|
|
87
110
|
json=json,
|
|
88
111
|
params=dict(params or {}),
|
|
89
112
|
headers=merged_headers,
|
|
90
|
-
timeout=self.timeout,
|
|
113
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
91
114
|
)
|
|
115
|
+
except requests.ReadTimeout as exc:
|
|
116
|
+
if path == "/api/v1/functional/select":
|
|
117
|
+
raise NetworkError(
|
|
118
|
+
"Selection read timed out. The backend may still complete the run. "
|
|
119
|
+
"Check existing runs before requesting another selection; this request was not retried."
|
|
120
|
+
) from exc
|
|
121
|
+
raise NetworkError(self.safe_error(f"Unable to reach Quantik Mind API: {exc}")) from exc
|
|
92
122
|
except requests.RequestException as exc:
|
|
93
|
-
raise NetworkError(f"Unable to reach Quantik Mind API: {exc}") from exc
|
|
123
|
+
raise NetworkError(self.safe_error(f"Unable to reach Quantik Mind API: {exc}")) from exc
|
|
94
124
|
|
|
95
125
|
if response.status_code in {401, 403}:
|
|
96
|
-
raise AuthenticationError(_error_message(response, "Authentication failed"))
|
|
126
|
+
raise AuthenticationError(self.safe_error(_error_message(response, "Authentication failed")))
|
|
97
127
|
if response.status_code >= 400:
|
|
98
|
-
raise ApiError(_error_message(response, f"API request failed with {response.status_code}"))
|
|
128
|
+
raise ApiError(self.safe_error(_error_message(response, f"API request failed with {response.status_code}")))
|
|
99
129
|
|
|
100
130
|
if not response.content:
|
|
101
131
|
return None
|
|
@@ -179,14 +209,20 @@ class QuantikMindApiClient:
|
|
|
179
209
|
project_id: str,
|
|
180
210
|
*,
|
|
181
211
|
changed_files: list[str] | None = None,
|
|
212
|
+
tau: float | None = None,
|
|
182
213
|
) -> dict[str, Any]:
|
|
183
214
|
params: dict[str, Any] = {"project_id": project_id}
|
|
184
215
|
if changed_files:
|
|
185
216
|
params["changed_files"] = changed_files
|
|
217
|
+
if tau is not None:
|
|
218
|
+
if not math.isfinite(float(tau)) or not 0.05 <= float(tau) <= 0.95:
|
|
219
|
+
raise ApiError("tau must be a finite number between 0.05 and 0.95")
|
|
220
|
+
params["tau"] = float(tau)
|
|
186
221
|
payload = self.request(
|
|
187
222
|
"GET",
|
|
188
223
|
"/api/v1/functional/select",
|
|
189
224
|
params=params,
|
|
225
|
+
timeout=(self.timeout, self.selection_read_timeout_seconds),
|
|
190
226
|
)
|
|
191
227
|
if not isinstance(payload, dict):
|
|
192
228
|
raise ApiError("API returned an unexpected functional selection response")
|
|
@@ -265,6 +301,12 @@ class QuantikMindApiClient:
|
|
|
265
301
|
raise ApiError("API returned an unexpected observability configuration response")
|
|
266
302
|
return response
|
|
267
303
|
|
|
304
|
+
def track_funnel_event(self, event_type: str, metadata: Mapping[str, Any] | None = None) -> None:
|
|
305
|
+
self.request("POST", "/api/v1/funnel/event", json={
|
|
306
|
+
"eventType": event_type, "eventSource": "ci" if os.getenv("CI") else "cli",
|
|
307
|
+
"metadata": dict(metadata or {}),
|
|
308
|
+
})
|
|
309
|
+
|
|
268
310
|
def get_observability_status(self, project_id: str) -> dict[str, Any]:
|
|
269
311
|
response = self.request(
|
|
270
312
|
"GET",
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from datetime import datetime, timezone
|
|
4
4
|
import json
|
|
5
|
+
import math
|
|
5
6
|
import os
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
import time
|
|
@@ -13,7 +14,7 @@ from rich.table import Table
|
|
|
13
14
|
import yaml
|
|
14
15
|
|
|
15
16
|
from .api import AgentMetricsEndpointUnavailable, ApiError, AuthenticationError, NetworkError, QuantikMindApiClient
|
|
16
|
-
from .agent.prometheus import PrometheusClient, PrometheusError
|
|
17
|
+
from .agent.prometheus import AGENT_METRICS_CONTRACT_VERSION, PrometheusClient, PrometheusError
|
|
17
18
|
from .config import (
|
|
18
19
|
DEFAULT_BASE_URL,
|
|
19
20
|
ConfigError,
|
|
@@ -39,6 +40,7 @@ from .subset import (
|
|
|
39
40
|
selected_test_selectors,
|
|
40
41
|
selection_stats,
|
|
41
42
|
)
|
|
43
|
+
from .telemetry import track_cli_event
|
|
42
44
|
|
|
43
45
|
|
|
44
46
|
console = Console(width=120)
|
|
@@ -234,6 +236,7 @@ def init(
|
|
|
234
236
|
{"api_key": api_key, "auth_mode": _auth_mode_for_api_key(api_key, existing_credentials)},
|
|
235
237
|
path=credentials_path(),
|
|
236
238
|
)
|
|
239
|
+
track_cli_event(client, "cli_initialized", {"command": "init"})
|
|
237
240
|
|
|
238
241
|
table = Table(title="Quantik Mind setup")
|
|
239
242
|
table.add_column("Setting")
|
|
@@ -423,12 +426,32 @@ def record_tests(results_dir: Path | None) -> None:
|
|
|
423
426
|
is_flag=True,
|
|
424
427
|
help="Print the complete selection payload as machine-readable JSON.",
|
|
425
428
|
)
|
|
429
|
+
@click.option("--wait", "wait_for_run", is_flag=True, help="Wait for saved sync/async completion without resubmitting.")
|
|
430
|
+
@click.option("--timeout-seconds", type=click.FloatRange(min=0.1), default=600)
|
|
431
|
+
@click.option("--poll-interval-seconds", type=click.FloatRange(min=0.1), default=3)
|
|
432
|
+
@click.option("--receipt-file", type=click.Path(path_type=Path, dir_okay=False))
|
|
433
|
+
@click.option("--expected-influence", type=click.Choice(["off", "on"]))
|
|
434
|
+
@click.option(
|
|
435
|
+
"--tau",
|
|
436
|
+
type=float,
|
|
437
|
+
default=None,
|
|
438
|
+
help=(
|
|
439
|
+
"Per-run selection breadth (0.05-0.95). Lower is more restrictive; "
|
|
440
|
+
"actual size also depends on the probability distribution and coverage guardrails."
|
|
441
|
+
),
|
|
442
|
+
)
|
|
426
443
|
def subset(
|
|
427
444
|
framework: str | None,
|
|
428
445
|
internal_ids: bool,
|
|
429
446
|
changed_files: tuple[str, ...],
|
|
430
447
|
changed_files_file: Path | None,
|
|
431
448
|
json_output: bool,
|
|
449
|
+
wait_for_run: bool,
|
|
450
|
+
timeout_seconds: float,
|
|
451
|
+
poll_interval_seconds: float,
|
|
452
|
+
receipt_file: Path | None,
|
|
453
|
+
expected_influence: str | None,
|
|
454
|
+
tau: float | None,
|
|
432
455
|
) -> None:
|
|
433
456
|
"""Request or inspect selected test subsets."""
|
|
434
457
|
try:
|
|
@@ -452,16 +475,43 @@ def subset(
|
|
|
452
475
|
except ConfigError as exc:
|
|
453
476
|
raise click.ClickException(f"{exc}. Run `qmind init` or set up ~/.qmind/credentials.yaml.") from exc
|
|
454
477
|
|
|
455
|
-
|
|
478
|
+
try:
|
|
479
|
+
client = QuantikMindApiClient.from_config(repo_config, credentials)
|
|
480
|
+
except ConfigError as exc:
|
|
481
|
+
raise click.ClickException(str(exc)) from exc
|
|
482
|
+
if tau is not None and (not math.isfinite(tau) or not 0.05 <= tau <= 0.95):
|
|
483
|
+
raise click.BadParameter(
|
|
484
|
+
"must be a finite number between 0.05 and 0.95", param_hint="--tau"
|
|
485
|
+
)
|
|
486
|
+
if wait_for_run or receipt_file or expected_influence:
|
|
487
|
+
from .runs import submit, emit
|
|
488
|
+
explicit = _resolve_subset_changed_files(changed_files=changed_files, changed_files_file=changed_files_file)
|
|
489
|
+
result = submit(client, project_id, explicit, tau=tau, wait=wait_for_run, timeout=timeout_seconds,
|
|
490
|
+
poll_interval=poll_interval_seconds, receipt_file=receipt_file, expected=expected_influence)
|
|
491
|
+
emit(result)
|
|
492
|
+
return
|
|
493
|
+
telemetry_state = load_agent_state()
|
|
494
|
+
is_first_run = not bool(telemetry_state.get("first_selection_completed"))
|
|
495
|
+
if is_first_run:
|
|
496
|
+
track_cli_event(client, "first_run_started", {"command": "subset"})
|
|
497
|
+
started_at = time.monotonic()
|
|
456
498
|
try:
|
|
457
499
|
explicit_changed_files = _resolve_subset_changed_files(
|
|
458
500
|
changed_files=changed_files,
|
|
459
501
|
changed_files_file=changed_files_file,
|
|
460
502
|
)
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
)
|
|
503
|
+
selection_kwargs = {"changed_files": explicit_changed_files}
|
|
504
|
+
if tau is not None:
|
|
505
|
+
selection_kwargs["tau"] = tau
|
|
506
|
+
response = client.select_functional_tests(project_id, **selection_kwargs)
|
|
507
|
+
if "selected" not in response and response.get("status") in {"pending", "queued", "running"} and response.get("run_id"):
|
|
508
|
+
from .runs import RunError, uuid_value, write_json
|
|
509
|
+
run_id = uuid_value(response["run_id"])
|
|
510
|
+
receipt = Path.cwd() / ".qmind" / "receipts" / (run_id + ".json")
|
|
511
|
+
write_json(receipt, {"schema_version": 1, "run_id": run_id, "project_id": project_id, "status": response["status"], "submission_outcome": "known"})
|
|
512
|
+
if json_output:
|
|
513
|
+
click.echo(json.dumps(response, sort_keys=True))
|
|
514
|
+
raise RunError(f"Selection accepted as {run_id}. Receipt: {receipt}. Use qmind runs wait/export; do not resubmit.", 3)
|
|
465
515
|
selectors = selected_test_selectors(response, use_internal_ids=internal_ids)
|
|
466
516
|
if json_output:
|
|
467
517
|
formatted = json.dumps(
|
|
@@ -472,12 +522,28 @@ def subset(
|
|
|
472
522
|
else:
|
|
473
523
|
formatted = format_test_selectors(selected_framework, selectors)
|
|
474
524
|
except AuthenticationError as exc:
|
|
525
|
+
track_cli_event(client, "test_selection_failed", {"command": "subset", "error_code": "authentication_error"})
|
|
475
526
|
raise click.ClickException(f"Invalid Quantik Mind credentials: {exc}. Run `qmind init` with a valid API key.") from exc
|
|
476
527
|
except (NetworkError, ApiError) as exc:
|
|
528
|
+
track_cli_event(client, "test_selection_failed", {"command": "subset", "error_code": "api_error"})
|
|
477
529
|
raise click.ClickException(f"Could not select tests: {exc}") from exc
|
|
478
530
|
except SubsetError as exc:
|
|
531
|
+
track_cli_event(client, "test_selection_failed", {"command": "subset", "error_code": "response_error"})
|
|
479
532
|
raise click.ClickException(str(exc)) from exc
|
|
480
533
|
|
|
534
|
+
counts = response.get("counts", {}) if isinstance(response, dict) else {}
|
|
535
|
+
event_metadata = {"command": "subset", "run_id": response.get("run_id"),
|
|
536
|
+
"test_count": counts.get("total"), "selected_test_count": counts.get("selected"),
|
|
537
|
+
"skipped_test_count": counts.get("skipped"), "duration_ms": int((time.monotonic()-started_at)*1000)}
|
|
538
|
+
if is_first_run:
|
|
539
|
+
track_cli_event(client, "first_run_completed", event_metadata)
|
|
540
|
+
telemetry_state["first_selection_completed"] = True
|
|
541
|
+
try:
|
|
542
|
+
write_agent_state(telemetry_state)
|
|
543
|
+
except Exception:
|
|
544
|
+
pass
|
|
545
|
+
track_cli_event(client, "test_selection_executed", event_metadata)
|
|
546
|
+
|
|
481
547
|
stats = selection_stats(response)
|
|
482
548
|
if stats and not json_output:
|
|
483
549
|
click.echo(stats, err=True)
|
|
@@ -1035,6 +1101,7 @@ def _run_agent_cycle(
|
|
|
1035
1101
|
payload = {
|
|
1036
1102
|
"project_id": project_id,
|
|
1037
1103
|
"timestamp": timestamp,
|
|
1104
|
+
"contract_version": AGENT_METRICS_CONTRACT_VERSION,
|
|
1038
1105
|
"metrics": metrics,
|
|
1039
1106
|
}
|
|
1040
1107
|
try:
|
|
@@ -1668,5 +1735,9 @@ def _display_value(value: object) -> str:
|
|
|
1668
1735
|
return "unknown" if value is None else str(value)
|
|
1669
1736
|
|
|
1670
1737
|
|
|
1738
|
+
from .runs import register as _register_saved_runs
|
|
1739
|
+
|
|
1740
|
+
_register_saved_runs(cli)
|
|
1741
|
+
|
|
1671
1742
|
if __name__ == "__main__":
|
|
1672
1743
|
cli()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Any, Mapping
|
|
5
6
|
|
|
@@ -23,6 +24,9 @@ def repo_config_path(root: str | Path = ".") -> Path:
|
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
def credentials_path(home: str | Path | None = None) -> Path:
|
|
27
|
+
override = os.environ.get("QMIND_CREDENTIALS_PATH") if home is None else None
|
|
28
|
+
if override:
|
|
29
|
+
return Path(override).expanduser()
|
|
26
30
|
base = Path(home) if home is not None else Path.home()
|
|
27
31
|
return base / CREDENTIALS_DIR / CREDENTIALS_FILE
|
|
28
32
|
|