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,111 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Mapping
5
+
6
+ import yaml
7
+
8
+
9
+ DEFAULT_BASE_URL = "https://api.quantikmind.com"
10
+ REPO_CONFIG_FILE = "qmind.yaml"
11
+ CREDENTIALS_DIR = ".qmind"
12
+ CREDENTIALS_FILE = "credentials.yaml"
13
+ AGENT_STATE_FILE = "agent-state.yaml"
14
+ SECRET_KEYS = {"api_key", "token", "access_token", "refresh_token", "secret", "credential"}
15
+
16
+
17
+ class ConfigError(RuntimeError):
18
+ """Raised when CLI configuration cannot be loaded or written."""
19
+
20
+
21
+ def repo_config_path(root: str | Path = ".") -> Path:
22
+ return Path(root) / REPO_CONFIG_FILE
23
+
24
+
25
+ def credentials_path(home: str | Path | None = None) -> Path:
26
+ base = Path(home) if home is not None else Path.home()
27
+ return base / CREDENTIALS_DIR / CREDENTIALS_FILE
28
+
29
+
30
+ def agent_state_path(home: str | Path | None = None) -> Path:
31
+ base = Path(home) if home is not None else Path.home()
32
+ return base / CREDENTIALS_DIR / AGENT_STATE_FILE
33
+
34
+
35
+ def missing_repo_config(root: str | Path = ".") -> bool:
36
+ return not repo_config_path(root).exists()
37
+
38
+
39
+ def load_repo_config(root: str | Path = ".") -> dict[str, Any]:
40
+ path = repo_config_path(root)
41
+ if not path.exists():
42
+ raise ConfigError(f"Missing repo config: {path}")
43
+ return _read_yaml_mapping(path)
44
+
45
+
46
+ def write_repo_config(config: Mapping[str, Any], root: str | Path = ".") -> Path:
47
+ sanitized = _strip_secrets(dict(config))
48
+ path = repo_config_path(root)
49
+ _write_yaml_mapping(path, sanitized)
50
+ return path
51
+
52
+
53
+ def load_credentials(path: str | Path | None = None) -> dict[str, Any]:
54
+ credentials_file = Path(path) if path is not None else credentials_path()
55
+ if not credentials_file.exists():
56
+ raise ConfigError(f"Missing credentials: {credentials_file}")
57
+ return _read_yaml_mapping(credentials_file)
58
+
59
+
60
+ def write_credentials(
61
+ credentials: Mapping[str, Any],
62
+ path: str | Path | None = None,
63
+ ) -> Path:
64
+ credentials_file = Path(path) if path is not None else credentials_path()
65
+ _write_yaml_mapping(credentials_file, dict(credentials))
66
+ return credentials_file
67
+
68
+
69
+ def load_agent_state(path: str | Path | None = None) -> dict[str, Any]:
70
+ state_file = Path(path) if path is not None else agent_state_path()
71
+ if not state_file.exists():
72
+ return {}
73
+ return _read_yaml_mapping(state_file)
74
+
75
+
76
+ def write_agent_state(state: Mapping[str, Any], path: str | Path | None = None) -> Path:
77
+ state_file = Path(path) if path is not None else agent_state_path()
78
+ _write_yaml_mapping(state_file, dict(state))
79
+ return state_file
80
+
81
+
82
+ def resolve_base_url(config: Mapping[str, Any] | None = None) -> str:
83
+ value = (config or {}).get("api_url") or (config or {}).get("base_url") or DEFAULT_BASE_URL
84
+ return str(value).rstrip("/")
85
+
86
+
87
+ def _read_yaml_mapping(path: Path) -> dict[str, Any]:
88
+ try:
89
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
90
+ except yaml.YAMLError as exc:
91
+ raise ConfigError(f"Invalid YAML in {path}: {exc}") from exc
92
+ if not isinstance(data, dict):
93
+ raise ConfigError(f"Expected a YAML mapping in {path}")
94
+ return data
95
+
96
+
97
+ def _write_yaml_mapping(path: Path, data: Mapping[str, Any]) -> None:
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ path.write_text(
100
+ yaml.safe_dump(dict(data), sort_keys=False),
101
+ encoding="utf-8",
102
+ )
103
+
104
+
105
+ def _strip_secrets(data: dict[str, Any]) -> dict[str, Any]:
106
+ for key, value in list(data.items()):
107
+ if key.lower() in SECRET_KEYS:
108
+ data.pop(key)
109
+ elif isinstance(value, dict):
110
+ data[key] = _strip_secrets(value)
111
+ return data
quantikmind_cli/git.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import subprocess
5
+
6
+
7
+ class GitError(RuntimeError):
8
+ """Raised when git metadata cannot be read."""
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BuildMetadata:
13
+ commit_hash: str
14
+ message: str
15
+ author: str
16
+ timestamp: str
17
+ branch: str | None
18
+ files_changed: list[str]
19
+
20
+ @property
21
+ def short_hash(self) -> str:
22
+ return self.commit_hash[:7]
23
+
24
+
25
+ def get_current_build_metadata() -> BuildMetadata:
26
+ commit_hash = _git("rev-parse", "HEAD")
27
+ message = _git("log", "-1", "--format=%B").strip()
28
+ author = _git("log", "-1", "--format=%an <%ae>")
29
+ timestamp = _git("log", "-1", "--format=%cI")
30
+ branch = _current_branch()
31
+ files_changed = _changed_files()
32
+
33
+ if not message:
34
+ message = commit_hash
35
+
36
+ return BuildMetadata(
37
+ commit_hash=commit_hash,
38
+ message=message,
39
+ author=author,
40
+ timestamp=timestamp,
41
+ branch=branch,
42
+ files_changed=files_changed,
43
+ )
44
+
45
+
46
+ def _current_branch() -> str | None:
47
+ branch = _git("branch", "--show-current")
48
+ if branch:
49
+ return branch
50
+
51
+ fallback = _git("rev-parse", "--abbrev-ref", "HEAD")
52
+ if fallback and fallback != "HEAD":
53
+ return fallback
54
+ return None
55
+
56
+
57
+ def _changed_files() -> list[str]:
58
+ try:
59
+ output = _git("diff", "--name-only", "HEAD~1", "HEAD")
60
+ except GitError:
61
+ output = _git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "HEAD")
62
+ return [line.strip() for line in output.splitlines() if line.strip()]
63
+
64
+
65
+ def _git(*args: str) -> str:
66
+ try:
67
+ completed = subprocess.run(
68
+ ("git", *args),
69
+ check=True,
70
+ capture_output=True,
71
+ text=True,
72
+ )
73
+ except FileNotFoundError as exc:
74
+ raise GitError("Git is not installed or is not available on PATH.") from exc
75
+ except subprocess.CalledProcessError as exc:
76
+ detail = (exc.stderr or exc.stdout or "").strip()
77
+ command = "git " + " ".join(args)
78
+ message = f"Git command failed: {command}"
79
+ if detail:
80
+ message = f"{message}: {detail}"
81
+ raise GitError(message) from exc
82
+
83
+ return completed.stdout.strip()
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ BUSINESS_CRITICALITIES = {"low", "medium", "high", "critical"}
11
+ REQUIRED_FIELDS = ("test_id", "name", "framework", "service")
12
+ OPTIONAL_PAYLOAD_FIELDS = (
13
+ "repo_ref",
14
+ "code_mapping",
15
+ "tags",
16
+ "markers",
17
+ "metadata",
18
+ "file",
19
+ "path",
20
+ "test_level",
21
+ "timeout_ms",
22
+ "parallelizable",
23
+ "constraints",
24
+ "avg_duration_ms",
25
+ )
26
+ DURATION_FIELDS = ("avg_duration_ms", "duration_ms", "estimated_duration_ms")
27
+
28
+
29
+ class ManifestImportError(RuntimeError):
30
+ """Raised when an enterprise test manifest cannot be imported."""
31
+
32
+
33
+ def load_manifest_payload(path: str | Path) -> list[dict[str, Any]]:
34
+ manifest_path = Path(path)
35
+ if not manifest_path.exists():
36
+ raise ManifestImportError(f"Manifest file does not exist: {manifest_path}")
37
+ if not manifest_path.is_file():
38
+ raise ManifestImportError(f"Manifest path is not a file: {manifest_path}")
39
+
40
+ data = _read_manifest(manifest_path)
41
+ if not isinstance(data, dict):
42
+ raise ManifestImportError("Manifest top-level value must be an object with a tests list.")
43
+
44
+ tests = data.get("tests")
45
+ if not isinstance(tests, list):
46
+ raise ManifestImportError("Manifest must include top-level tests as a list.")
47
+
48
+ payload: list[dict[str, Any]] = []
49
+ for index, item in enumerate(tests):
50
+ if not isinstance(item, dict):
51
+ raise ManifestImportError(f"Manifest test at index {index} must be an object.")
52
+ payload.append(_manifest_test_payload(item, index))
53
+ return payload
54
+
55
+
56
+ def _read_manifest(path: Path) -> Any:
57
+ try:
58
+ text = path.read_text(encoding="utf-8")
59
+ except OSError as exc:
60
+ raise ManifestImportError(f"Could not read manifest {path}: {exc}") from exc
61
+
62
+ if path.suffix.lower() == ".json":
63
+ try:
64
+ return json.loads(text)
65
+ except json.JSONDecodeError as exc:
66
+ raise ManifestImportError(f"Invalid JSON in manifest {path}: {exc}") from exc
67
+
68
+ try:
69
+ return yaml.safe_load(text)
70
+ except yaml.YAMLError as exc:
71
+ raise ManifestImportError(f"Invalid YAML in manifest {path}: {exc}") from exc
72
+
73
+
74
+ def _manifest_test_payload(item: dict[str, Any], index: int) -> dict[str, Any]:
75
+ normalized = _normalize_manifest_item(item)
76
+ missing = [field for field in REQUIRED_FIELDS if _blank(normalized.get(field))]
77
+ if missing:
78
+ fields = ", ".join(missing)
79
+ raise ManifestImportError(f"Manifest test at index {index} is missing required field(s): {fields}.")
80
+
81
+ criticality = str(normalized.get("business_criticality") or "medium").strip().lower()
82
+ if criticality not in BUSINESS_CRITICALITIES:
83
+ allowed = ", ".join(sorted(BUSINESS_CRITICALITIES))
84
+ raise ManifestImportError(
85
+ f"Manifest test at index {index} has invalid business_criticality "
86
+ f"{criticality!r}. Allowed values: {allowed}."
87
+ )
88
+
89
+ payload: dict[str, Any] = {
90
+ "test_id": str(normalized["test_id"]).strip(),
91
+ "name": str(normalized["name"]).strip(),
92
+ "framework": str(normalized["framework"]).strip(),
93
+ "service": str(normalized["service"]).strip(),
94
+ "suite": str(normalized.get("suite") or "default").strip() or "default",
95
+ "business_criticality": criticality,
96
+ }
97
+ for field in OPTIONAL_PAYLOAD_FIELDS:
98
+ if field in normalized and normalized[field] is not None:
99
+ payload[field] = normalized[field]
100
+ return payload
101
+
102
+
103
+ def _normalize_manifest_item(item: dict[str, Any]) -> dict[str, Any]:
104
+ normalized = dict(item)
105
+ if "test_id" not in normalized and "id" in normalized:
106
+ normalized["test_id"] = normalized["id"]
107
+ if "name" not in normalized and normalized.get("test_id") is not None:
108
+ normalized["name"] = normalized["test_id"]
109
+ if "business_criticality" not in normalized and "criticality" in normalized:
110
+ normalized["business_criticality"] = normalized["criticality"]
111
+ if "tags" not in normalized and "markers" in normalized:
112
+ normalized["tags"] = normalized["markers"]
113
+ duration = _resolve_duration_ms(normalized)
114
+ if duration is not None:
115
+ normalized["avg_duration_ms"] = duration
116
+ return normalized
117
+
118
+
119
+ def _blank(value: Any) -> bool:
120
+ return value is None or str(value).strip() == ""
121
+
122
+
123
+ def _resolve_duration_ms(item: dict[str, Any]) -> int | float | None:
124
+ for field in DURATION_FIELDS:
125
+ value = item.get(field)
126
+ if value is not None and not _blank(value):
127
+ return value
128
+
129
+ constraints = item.get("constraints")
130
+ if isinstance(constraints, dict):
131
+ value = constraints.get("estimated_duration_ms")
132
+ if value is not None and not _blank(value):
133
+ return value
134
+ return None
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ import re
7
+ import xml.etree.ElementTree as ET
8
+ from typing import Any
9
+
10
+ JUNIT_BROWSER_PREFIXES = ("chromium", "firefox", "webkit")
11
+ PATH_LIKE_TEST_EXTENSIONS = (
12
+ ".py",
13
+ ".cy.js",
14
+ ".cy.ts",
15
+ ".spec.js",
16
+ ".spec.ts",
17
+ ".test.js",
18
+ ".test.ts",
19
+ ".robot",
20
+ )
21
+
22
+
23
+ class ResultsImportError(RuntimeError):
24
+ """Raised when test result records cannot be loaded."""
25
+
26
+
27
+ @dataclass
28
+ class ResultsParseResult:
29
+ records: list[dict[str, Any]] = field(default_factory=list)
30
+ warnings: list[str] = field(default_factory=list)
31
+
32
+
33
+ def collect_junit_records(results_dir: str | Path) -> ResultsParseResult:
34
+ root = Path(results_dir)
35
+ if not root.exists():
36
+ raise ResultsImportError(f"Results directory does not exist: {root}")
37
+ if not root.is_dir():
38
+ raise ResultsImportError(f"Results path is not a directory: {root}")
39
+
40
+ files = [path for path in root.rglob("*") if path.is_file()]
41
+ result = ResultsParseResult()
42
+ if not files:
43
+ result.warnings.append(f"Results directory is empty: {root}")
44
+ return result
45
+
46
+ for path in files:
47
+ parsed = parse_junit_file(path)
48
+ result.records.extend(parsed.records)
49
+ result.warnings.extend(parsed.warnings)
50
+
51
+ return result
52
+
53
+
54
+ def parse_junit_file(path: str | Path) -> ResultsParseResult:
55
+ report_path = Path(path)
56
+ try:
57
+ xml_root = ET.parse(report_path).getroot()
58
+ except ET.ParseError as exc:
59
+ return ResultsParseResult(warnings=[f"Malformed XML in {report_path}: {exc}"])
60
+ except OSError as exc:
61
+ return ResultsParseResult(warnings=[f"Could not read {report_path}: {exc}"])
62
+
63
+ suite_elements = _suite_elements(xml_root)
64
+ if not suite_elements:
65
+ return ResultsParseResult(warnings=[f"Unsupported JUnit XML shape in {report_path}"])
66
+
67
+ records: list[dict[str, Any]] = []
68
+ warnings: list[str] = []
69
+ fallback_executed_at = _file_mtime_iso(report_path)
70
+
71
+ for suite in suite_elements:
72
+ executed_at = _suite_timestamp(suite) or fallback_executed_at
73
+ suite_properties = _properties(suite)
74
+ for testcase in _children_named(suite, "testcase"):
75
+ record = _record_from_testcase(
76
+ testcase,
77
+ executed_at=executed_at,
78
+ suite_properties=suite_properties,
79
+ )
80
+ if record:
81
+ records.append(record)
82
+
83
+ if not records:
84
+ warnings.append(f"No usable test cases found in {report_path}")
85
+
86
+ return ResultsParseResult(records=records, warnings=warnings)
87
+
88
+
89
+ def _suite_elements(root: ET.Element) -> list[ET.Element]:
90
+ if _local_name(root.tag) == "testsuite":
91
+ return [root]
92
+ if _local_name(root.tag) == "testsuites":
93
+ return [child for child in _children_named(root, "testsuite")]
94
+ return []
95
+
96
+
97
+ def _record_from_testcase(
98
+ testcase: ET.Element,
99
+ *,
100
+ executed_at: str,
101
+ suite_properties: dict[str, str] | None = None,
102
+ ) -> dict[str, Any] | None:
103
+ name = _normalize_junit_id_part(testcase.attrib.get("name"))
104
+ classname = _normalize_junit_id_part(testcase.attrib.get("classname"))
105
+ if not name:
106
+ return None
107
+
108
+ testcase_properties = {**(suite_properties or {}), **_properties(testcase)}
109
+ test_id = (
110
+ _normalize_junit_test_id(testcase_properties.get("test_id"))
111
+ or _junit_test_id(classname=classname, name=name, file_attr=testcase.attrib.get("file"))
112
+ )
113
+ record = {
114
+ "test_id": test_id,
115
+ "status": _status_from_testcase(testcase),
116
+ "duration_ms": _duration_ms(testcase.attrib.get("time")),
117
+ "executed_at": executed_at,
118
+ }
119
+ for key in ("test_name", "service", "commit_sha", "build_id"):
120
+ value = testcase_properties.get(key)
121
+ if value:
122
+ record[key] = value
123
+ return record
124
+
125
+
126
+ def _properties(element: ET.Element) -> dict[str, str]:
127
+ properties: dict[str, str] = {}
128
+ for child in _children_named(element, "properties"):
129
+ for prop in _children_named(child, "property"):
130
+ name = _normalize_junit_id_part(prop.attrib.get("name"))
131
+ value = _normalize_junit_id_part(prop.attrib.get("value"))
132
+ if name and value:
133
+ properties[name] = value
134
+ return properties
135
+
136
+
137
+ def _junit_test_id(*, classname: str, name: str, file_attr: str | None) -> str:
138
+ """Build the primary history identifier without inventing backend aliases.
139
+
140
+ Prefer IDs that match `qmind sync library` scanner output when JUnit exposes
141
+ enough identity. Keep generic JUnit fallback deterministic instead of
142
+ guessing from ambiguous names alone.
143
+ """
144
+ file_path = _safe_junit_file_path(file_attr) or _safe_junit_file_path(classname)
145
+ if file_path:
146
+ return f"{file_path}::{name}"
147
+ pytest_id = _pytest_junit_test_id(classname=classname, name=name)
148
+ if pytest_id:
149
+ return pytest_id
150
+ java_id = _java_junit_test_id(classname=classname, name=name)
151
+ if java_id:
152
+ return java_id
153
+ if classname:
154
+ return _normalize_junit_test_id(f"{classname}.{name}")
155
+ return _normalize_junit_test_id(name)
156
+
157
+
158
+ def _normalize_junit_test_id(value: str | None) -> str:
159
+ return _normalize_junit_id_part(value)
160
+
161
+
162
+ def _normalize_junit_id_part(value: str | None) -> str:
163
+ normalized = re.sub(r"\s+", " ", str(value or "").replace("\\", "/")).strip()
164
+ return normalized
165
+
166
+
167
+ def _safe_junit_file_path(value: str | None) -> str | None:
168
+ text = _normalize_junit_id_part(value)
169
+ if not text:
170
+ return None
171
+
172
+ parts = text.split(" ", 1)
173
+ if len(parts) == 2 and parts[0].lower() in JUNIT_BROWSER_PREFIXES:
174
+ return _path_token_if_supported(parts[1])
175
+
176
+ direct = _path_token_if_supported(text)
177
+ if direct:
178
+ return direct
179
+
180
+ return None
181
+
182
+
183
+ def _pytest_junit_test_id(*, classname: str, name: str) -> str | None:
184
+ if not classname or not name.startswith("test_"):
185
+ return None
186
+
187
+ parts = [part for part in classname.split(".") if part]
188
+ if not parts or not _looks_like_pytest_module(parts):
189
+ return None
190
+
191
+ class_name: str | None = None
192
+ module_parts = parts
193
+ if parts[-1].startswith("Test"):
194
+ class_name = parts[-1]
195
+ module_parts = parts[:-1]
196
+
197
+ if not module_parts:
198
+ return None
199
+
200
+ module_path = "/".join(module_parts) + ".py"
201
+ if class_name:
202
+ return f"{module_path}::{class_name}::{name}"
203
+ return f"{module_path}::{name}"
204
+
205
+
206
+ def _looks_like_pytest_module(parts: list[str]) -> bool:
207
+ return any(part.startswith("test_") or part.endswith("_test") for part in parts)
208
+
209
+
210
+ def _java_junit_test_id(*, classname: str, name: str) -> str | None:
211
+ if not classname or not name or name.startswith("test_"):
212
+ return None
213
+
214
+ class_name = classname.rsplit(".", 1)[-1]
215
+ if not class_name.endswith("Test"):
216
+ return None
217
+
218
+ return f"{class_name}.{name}"
219
+
220
+
221
+ def _path_token_if_supported(value: str) -> str | None:
222
+ candidate = value.strip()
223
+ if "/" not in candidate:
224
+ return None
225
+ lowered = candidate.lower()
226
+ if any(lowered.endswith(extension) for extension in PATH_LIKE_TEST_EXTENSIONS):
227
+ return candidate
228
+ if "/cypress/integration/" in f"/{lowered}" and lowered.endswith((".js", ".ts")):
229
+ return candidate
230
+ return None
231
+
232
+
233
+ def _status_from_testcase(testcase: ET.Element) -> str:
234
+ child_names = {_local_name(child.tag) for child in list(testcase)}
235
+ if "failure" in child_names or "error" in child_names:
236
+ return "failed"
237
+ if "skipped" in child_names:
238
+ return "skipped"
239
+ return "passed"
240
+
241
+
242
+ def _duration_ms(value: str | None) -> int:
243
+ if value is None or not str(value).strip():
244
+ return 0
245
+ try:
246
+ seconds = float(str(value).strip())
247
+ except ValueError:
248
+ return 0
249
+ if seconds < 0:
250
+ return 0
251
+ return int(round(seconds * 1000))
252
+
253
+
254
+ def _suite_timestamp(suite: ET.Element) -> str | None:
255
+ timestamp = (suite.attrib.get("timestamp") or "").strip()
256
+ return timestamp or None
257
+
258
+
259
+ def _file_mtime_iso(path: Path) -> str:
260
+ return datetime.fromtimestamp(path.stat().st_mtime, timezone.utc).isoformat()
261
+
262
+
263
+ def _children_named(element: ET.Element, local_name: str) -> list[ET.Element]:
264
+ return [child for child in list(element) if _local_name(child.tag) == local_name]
265
+
266
+
267
+ def _local_name(tag: str) -> str:
268
+ return tag.rsplit("}", 1)[-1]