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,547 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ from typing import Any, Iterable
10
+
11
+
12
+ SYNC_LIBRARY_FRAMEWORKS = ("pytest", "jest", "maven", "gradle", "cypress", "playwright", "robot")
13
+ PYTEST_FILE_RE = re.compile(r"(^test_.*\.py$|.*_test\.py$)")
14
+ JEST_FILE_RE = re.compile(r".*(\.test|\.spec)\.(js|jsx|ts|tsx)$")
15
+ JAVA_TEST_FILE_RE = re.compile(r".*Test\.java$")
16
+ CYPRESS_FILE_RE = re.compile(r".*\.cy\.(js|ts)$")
17
+ PLAYWRIGHT_FILE_RE = re.compile(r".*(\.test|\.spec)\.(js|ts)$")
18
+ JEST_BLOCK_RE = re.compile(
19
+ r"\b(?P<kind>describe|it|test)(?:\.(?:only|skip|todo|each))?\s*\(\s*(?P<quote>['\"`])(?P<name>.*?)(?P=quote)",
20
+ re.DOTALL,
21
+ )
22
+ CYPRESS_BLOCK_RE = re.compile(
23
+ r"\b(?P<kind>describe|context|it|specify)(?:\.(?:only|skip))?\s*\(\s*"
24
+ r"(?P<quote>['\"`])(?P<name>.*?)(?P=quote)",
25
+ re.DOTALL,
26
+ )
27
+ PLAYWRIGHT_BLOCK_RE = re.compile(
28
+ r"\b(?P<kind>test(?:\.describe)?)(?:\.(?:only|skip|fixme))?\s*\(\s*"
29
+ r"(?P<quote>['\"`])(?P<name>.*?)(?P=quote)",
30
+ re.DOTALL,
31
+ )
32
+ JAVA_TEST_METHOD_RE = re.compile(
33
+ r"@Test(?:\s*\([^)]*\))?\s*(?:\r?\n\s*(?:@\w+(?:\([^)]*\))?\s*)*)*"
34
+ r"(?:public|private|protected)?\s*(?:static\s+)?(?:void|[\w<>\[\], ?]+)\s+(?P<name>\w+)\s*\(",
35
+ re.MULTILINE,
36
+ )
37
+ COMMON_PATH_PARTS = {
38
+ "__tests__",
39
+ "com",
40
+ "io",
41
+ "java",
42
+ "javascript",
43
+ "js",
44
+ "jvm",
45
+ "net",
46
+ "org",
47
+ "py",
48
+ "python",
49
+ "spec",
50
+ "specs",
51
+ "src",
52
+ "test",
53
+ "tests",
54
+ "ts",
55
+ "typescript",
56
+ "unit",
57
+ "integration",
58
+ }
59
+ HIGH_CRITICALITY_HINTS = ("checkout", "payment", "billing", "invoice", "auth", "login", "security")
60
+ CRITICAL_HINTS = ("critical", "p0", "sev0", "prod_down", "outage")
61
+ LOW_CRITICALITY_HINTS = ("smoke", "snapshot", "lint", "format", "style")
62
+
63
+
64
+ class LibraryScanError(RuntimeError):
65
+ """Raised when a functional test library cannot be generated."""
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class DiscoveredTest:
70
+ test_id: str
71
+ name: str
72
+ framework: str
73
+ path: Path
74
+ service: str
75
+ selector: str | None = None
76
+ suite: str = "default"
77
+ business_criticality: str | None = None
78
+
79
+ def to_payload(self, root: Path) -> dict[str, Any]:
80
+ relative_path = _relative_posix(self.path, root)
81
+ payload: dict[str, Any] = {
82
+ "test_id": self.test_id,
83
+ "name": self.name,
84
+ "service": self.service,
85
+ "suite": self.suite,
86
+ "framework": self.framework,
87
+ "repo_ref": {"path": relative_path},
88
+ "code_mapping": {"file_globs": [relative_path]},
89
+ }
90
+ if self.selector:
91
+ payload["repo_ref"]["selector"] = self.selector
92
+ if self.business_criticality:
93
+ payload["business_criticality"] = self.business_criticality
94
+ return payload
95
+
96
+
97
+ @dataclass
98
+ class LibraryScanResult:
99
+ tests: list[DiscoveredTest] = field(default_factory=list)
100
+ warnings: list[str] = field(default_factory=list)
101
+
102
+ def payload(self, root: str | Path) -> list[dict[str, Any]]:
103
+ root_path = Path(root)
104
+ return [test.to_payload(root_path) for test in self.tests]
105
+
106
+
107
+ def scan_functional_library(
108
+ framework: str,
109
+ test_dir: str | Path,
110
+ *,
111
+ root: str | Path = ".",
112
+ ) -> LibraryScanResult:
113
+ if framework not in SYNC_LIBRARY_FRAMEWORKS:
114
+ raise LibraryScanError(f"Unsupported framework for qmind sync library: {framework}")
115
+
116
+ root_path = Path(root).resolve()
117
+ test_root = (root_path / test_dir).resolve()
118
+ if not test_root.exists():
119
+ raise LibraryScanError(f"Test directory does not exist: {test_dir}")
120
+ if not test_root.is_dir():
121
+ raise LibraryScanError(f"Test path is not a directory: {test_dir}")
122
+
123
+ if framework == "pytest":
124
+ return scan_pytest(test_root, root=root_path)
125
+ if framework == "jest":
126
+ return scan_jest(test_root, root=root_path)
127
+ if framework == "cypress":
128
+ return scan_cypress(test_root, root=root_path)
129
+ if framework == "playwright":
130
+ return scan_playwright(test_root, root=root_path)
131
+ if framework == "robot":
132
+ return scan_robot(test_root, root=root_path)
133
+ return scan_java(framework, test_root, root=root_path)
134
+
135
+
136
+ def scan_pytest(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
137
+ root_path = Path(root).resolve()
138
+ test_root_path = Path(test_root).resolve()
139
+ collected = _collect_pytest(test_root_path, root=root_path)
140
+ if collected.tests:
141
+ return collected
142
+
143
+ fallback = _scan_pytest_files(test_root_path, root=root_path)
144
+ fallback.warnings = [*collected.warnings, *fallback.warnings]
145
+ return fallback
146
+
147
+
148
+ def scan_jest(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
149
+ root_path = Path(root).resolve()
150
+ test_root_path = Path(test_root).resolve()
151
+ result = LibraryScanResult()
152
+
153
+ for path in _iter_files(test_root_path, JEST_FILE_RE):
154
+ try:
155
+ content = path.read_text(encoding="utf-8")
156
+ except UnicodeDecodeError:
157
+ result.warnings.append(f"Could not parse {path}: file is not UTF-8")
158
+ continue
159
+ except OSError as exc:
160
+ result.warnings.append(f"Could not read {path}: {exc}")
161
+ continue
162
+
163
+ matches = list(JEST_BLOCK_RE.finditer(content))
164
+ describe_stack: list[str] = []
165
+ for match in matches:
166
+ kind = match.group("kind")
167
+ name = _clean_name(match.group("name"))
168
+ if not name:
169
+ continue
170
+ if kind == "describe":
171
+ describe_stack = [name]
172
+ continue
173
+
174
+ display_name = " ".join([*describe_stack, name]).strip()
175
+ selector = display_name or name
176
+ result.tests.append(
177
+ _discovered_test(
178
+ framework="jest",
179
+ path=path,
180
+ root=root_path,
181
+ test_root=test_root_path,
182
+ name=display_name or name,
183
+ selector=selector,
184
+ test_id=f"{_relative_posix(path, root_path)}::{selector}",
185
+ )
186
+ )
187
+
188
+ return result
189
+
190
+
191
+ def scan_cypress(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
192
+ root_path = Path(root).resolve()
193
+ test_root_path = Path(test_root).resolve()
194
+ result = LibraryScanResult()
195
+
196
+ for path in _iter_cypress_files(test_root_path):
197
+ scanned = _scan_js_style_tests(
198
+ framework="cypress",
199
+ path=path,
200
+ root=root_path,
201
+ test_root=test_root_path,
202
+ pattern=CYPRESS_BLOCK_RE,
203
+ suite_kinds={"describe", "context"},
204
+ )
205
+ result.tests.extend(scanned.tests)
206
+ result.warnings.extend(scanned.warnings)
207
+
208
+ return result
209
+
210
+
211
+ def scan_playwright(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
212
+ root_path = Path(root).resolve()
213
+ test_root_path = Path(test_root).resolve()
214
+ result = LibraryScanResult()
215
+
216
+ for path in _iter_files(test_root_path, PLAYWRIGHT_FILE_RE):
217
+ scanned = _scan_js_style_tests(
218
+ framework="playwright",
219
+ path=path,
220
+ root=root_path,
221
+ test_root=test_root_path,
222
+ pattern=PLAYWRIGHT_BLOCK_RE,
223
+ suite_kinds={"test.describe"},
224
+ )
225
+ result.tests.extend(scanned.tests)
226
+ result.warnings.extend(scanned.warnings)
227
+
228
+ return result
229
+
230
+
231
+ def scan_robot(test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
232
+ root_path = Path(root).resolve()
233
+ test_root_path = Path(test_root).resolve()
234
+ result = LibraryScanResult()
235
+
236
+ for path in sorted(test_root_path.rglob("*.robot")):
237
+ try:
238
+ lines = path.read_text(encoding="utf-8").splitlines()
239
+ except UnicodeDecodeError:
240
+ result.warnings.append(f"Could not parse {path}: file is not UTF-8")
241
+ continue
242
+ except OSError as exc:
243
+ result.warnings.append(f"Could not read {path}: {exc}")
244
+ continue
245
+
246
+ in_test_cases = False
247
+ for line in lines:
248
+ stripped = line.strip()
249
+ if not stripped or stripped.startswith("#"):
250
+ continue
251
+ if stripped.startswith("***") and stripped.endswith("***"):
252
+ section = stripped.strip("*").strip().lower()
253
+ in_test_cases = section == "test cases"
254
+ continue
255
+ if not in_test_cases or line[:1].isspace():
256
+ continue
257
+
258
+ name = _clean_name(stripped)
259
+ selector = _relative_posix(path, root_path)
260
+ result.tests.append(
261
+ _discovered_test(
262
+ framework="robot",
263
+ path=path,
264
+ root=root_path,
265
+ test_root=test_root_path,
266
+ name=name,
267
+ selector=selector,
268
+ test_id=f"{selector}::{name}",
269
+ )
270
+ )
271
+
272
+ return result
273
+
274
+
275
+ def scan_java(framework: str, test_root: str | Path, *, root: str | Path = ".") -> LibraryScanResult:
276
+ root_path = Path(root).resolve()
277
+ test_root_path = Path(test_root).resolve()
278
+ result = LibraryScanResult()
279
+
280
+ for path in _iter_files(test_root_path, JAVA_TEST_FILE_RE):
281
+ try:
282
+ content = path.read_text(encoding="utf-8")
283
+ except UnicodeDecodeError:
284
+ result.warnings.append(f"Could not parse {path}: file is not UTF-8")
285
+ continue
286
+ except OSError as exc:
287
+ result.warnings.append(f"Could not read {path}: {exc}")
288
+ continue
289
+
290
+ class_name = path.stem
291
+ for match in JAVA_TEST_METHOD_RE.finditer(content):
292
+ method_name = match.group("name")
293
+ selector = f"{class_name}.{method_name}"
294
+ result.tests.append(
295
+ _discovered_test(
296
+ framework=framework,
297
+ path=path,
298
+ root=root_path,
299
+ test_root=test_root_path,
300
+ name=method_name,
301
+ selector=selector,
302
+ test_id=selector,
303
+ suite=class_name,
304
+ )
305
+ )
306
+
307
+ return result
308
+
309
+
310
+ def _scan_js_style_tests(
311
+ *,
312
+ framework: str,
313
+ path: Path,
314
+ root: Path,
315
+ test_root: Path,
316
+ pattern: re.Pattern[str],
317
+ suite_kinds: set[str],
318
+ ) -> LibraryScanResult:
319
+ result = LibraryScanResult()
320
+ try:
321
+ content = path.read_text(encoding="utf-8")
322
+ except UnicodeDecodeError:
323
+ result.warnings.append(f"Could not parse {path}: file is not UTF-8")
324
+ return result
325
+ except OSError as exc:
326
+ result.warnings.append(f"Could not read {path}: {exc}")
327
+ return result
328
+
329
+ relative_path = _relative_posix(path, root)
330
+ suite_name: str | None = None
331
+ for match in pattern.finditer(content):
332
+ kind = match.group("kind")
333
+ name = _clean_name(match.group("name"))
334
+ if not name:
335
+ continue
336
+ if kind in suite_kinds:
337
+ suite_name = name
338
+ continue
339
+
340
+ display_name = " ".join(part for part in (suite_name, name) if part).strip() or name
341
+ result.tests.append(
342
+ _discovered_test(
343
+ framework=framework,
344
+ path=path,
345
+ root=root,
346
+ test_root=test_root,
347
+ name=display_name,
348
+ selector=relative_path,
349
+ test_id=f"{relative_path}::{display_name}",
350
+ )
351
+ )
352
+
353
+ return result
354
+
355
+
356
+ def _collect_pytest(test_root: Path, *, root: Path) -> LibraryScanResult:
357
+ result = LibraryScanResult()
358
+ try:
359
+ completed = subprocess.run(
360
+ [sys.executable, "-m", "pytest", "--collect-only", "-q", str(test_root)],
361
+ cwd=root,
362
+ capture_output=True,
363
+ text=True,
364
+ timeout=60,
365
+ check=False,
366
+ )
367
+ except FileNotFoundError:
368
+ result.warnings.append("pytest is not available; falling back to Python source scanning.")
369
+ return result
370
+ except subprocess.SubprocessError as exc:
371
+ result.warnings.append(f"pytest collection failed: {exc}; falling back to Python source scanning.")
372
+ return result
373
+
374
+ if completed.returncode != 0:
375
+ details = (completed.stderr or completed.stdout).strip().splitlines()
376
+ suffix = f": {details[-1]}" if details else ""
377
+ result.warnings.append(f"pytest collection failed{suffix}; falling back to Python source scanning.")
378
+ return result
379
+
380
+ for line in completed.stdout.splitlines():
381
+ selector = line.strip()
382
+ if not selector or "::" not in selector or selector.startswith("<"):
383
+ continue
384
+ path_text, name_parts = selector.split("::", 1)
385
+ path = (root / path_text).resolve()
386
+ name = name_parts.split("::")[-1]
387
+ result.tests.append(
388
+ _discovered_test(
389
+ framework="pytest",
390
+ path=path,
391
+ root=root,
392
+ test_root=test_root,
393
+ name=name,
394
+ selector=selector,
395
+ test_id=selector,
396
+ suite=_suite_from_selector(name_parts),
397
+ )
398
+ )
399
+
400
+ return result
401
+
402
+
403
+ def _scan_pytest_files(test_root: Path, *, root: Path) -> LibraryScanResult:
404
+ result = LibraryScanResult()
405
+ for path in _iter_files(test_root, PYTEST_FILE_RE):
406
+ try:
407
+ tree = ast.parse(path.read_text(encoding="utf-8"))
408
+ except SyntaxError as exc:
409
+ result.warnings.append(f"Could not parse {path}: {exc.msg}")
410
+ continue
411
+ except UnicodeDecodeError:
412
+ result.warnings.append(f"Could not parse {path}: file is not UTF-8")
413
+ continue
414
+ except OSError as exc:
415
+ result.warnings.append(f"Could not read {path}: {exc}")
416
+ continue
417
+
418
+ for node in tree.body:
419
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
420
+ selector = f"{_relative_posix(path, root)}::{node.name}"
421
+ result.tests.append(
422
+ _discovered_test(
423
+ framework="pytest",
424
+ path=path,
425
+ root=root,
426
+ test_root=test_root,
427
+ name=node.name,
428
+ selector=selector,
429
+ test_id=selector,
430
+ )
431
+ )
432
+ elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
433
+ for child in node.body:
434
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and child.name.startswith("test_"):
435
+ selector = f"{_relative_posix(path, root)}::{node.name}::{child.name}"
436
+ result.tests.append(
437
+ _discovered_test(
438
+ framework="pytest",
439
+ path=path,
440
+ root=root,
441
+ test_root=test_root,
442
+ name=child.name,
443
+ selector=selector,
444
+ test_id=selector,
445
+ suite=node.name,
446
+ )
447
+ )
448
+
449
+ return result
450
+
451
+
452
+ def _discovered_test(
453
+ *,
454
+ framework: str,
455
+ path: Path,
456
+ root: Path,
457
+ test_root: Path,
458
+ name: str,
459
+ selector: str,
460
+ test_id: str,
461
+ suite: str = "default",
462
+ ) -> DiscoveredTest:
463
+ service = infer_service(path, root=root, test_root=test_root)
464
+ return DiscoveredTest(
465
+ test_id=test_id,
466
+ name=name,
467
+ framework=framework,
468
+ path=path,
469
+ service=service,
470
+ selector=selector,
471
+ suite=suite,
472
+ business_criticality=infer_business_criticality([name, selector, str(path)]),
473
+ )
474
+
475
+
476
+ def infer_service(path: str | Path, *, root: str | Path = ".", test_root: str | Path | None = None) -> str:
477
+ root_path = Path(root).resolve()
478
+ path_obj = Path(path).resolve()
479
+ try:
480
+ relative = path_obj.relative_to(Path(test_root).resolve() if test_root else root_path)
481
+ except ValueError:
482
+ try:
483
+ relative = path_obj.relative_to(root_path)
484
+ except ValueError:
485
+ relative = path_obj
486
+
487
+ parts = list(relative.parts[:-1])
488
+ lowered = [part.lower() for part in parts]
489
+ for marker in ("services", "service", "apps", "app", "packages", "modules"):
490
+ if marker in lowered:
491
+ index = lowered.index(marker)
492
+ if index + 1 < len(parts):
493
+ return _service_name(parts[index + 1])
494
+
495
+ candidates = [part for part in parts if part.lower() not in COMMON_PATH_PARTS]
496
+ if candidates:
497
+ return _service_name(candidates[-1])
498
+ return "default"
499
+
500
+
501
+ def infer_business_criticality(values: Iterable[str]) -> str:
502
+ text = " ".join(values).lower()
503
+ if any(hint in text for hint in CRITICAL_HINTS):
504
+ return "critical"
505
+ if any(hint in text for hint in HIGH_CRITICALITY_HINTS):
506
+ return "high"
507
+ if any(hint in text for hint in LOW_CRITICALITY_HINTS):
508
+ return "low"
509
+ return "medium"
510
+
511
+
512
+ def _iter_files(root: Path, pattern: re.Pattern[str]) -> Iterable[Path]:
513
+ for path in sorted(root.rglob("*")):
514
+ if path.is_file() and pattern.match(path.name):
515
+ yield path
516
+
517
+
518
+ def _iter_cypress_files(root: Path) -> Iterable[Path]:
519
+ for path in sorted(root.rglob("*")):
520
+ if not path.is_file():
521
+ continue
522
+ if CYPRESS_FILE_RE.match(path.name):
523
+ yield path
524
+ continue
525
+ parts = {part.lower() for part in path.parts}
526
+ if "integration" in parts and path.suffix.lower() in {".js", ".ts"}:
527
+ yield path
528
+
529
+
530
+ def _relative_posix(path: Path, root: Path) -> str:
531
+ try:
532
+ return path.resolve().relative_to(root.resolve()).as_posix()
533
+ except ValueError:
534
+ return path.as_posix()
535
+
536
+
537
+ def _suite_from_selector(selector: str) -> str:
538
+ parts = [part for part in selector.split("::") if part]
539
+ return parts[-2] if len(parts) > 1 else "default"
540
+
541
+
542
+ def _clean_name(value: str) -> str:
543
+ return re.sub(r"\s+", " ", value).strip()
544
+
545
+
546
+ def _service_name(value: str) -> str:
547
+ return re.sub(r"[^A-Za-z0-9_.-]+", "-", value).strip("-").lower() or "default"
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Iterable, Mapping
4
+
5
+
6
+ SUBSET_FRAMEWORKS = (
7
+ "pytest",
8
+ "jest",
9
+ "maven",
10
+ "gradle",
11
+ "cypress",
12
+ "playwright",
13
+ "rspec",
14
+ "cucumber",
15
+ "robot",
16
+ )
17
+
18
+
19
+ class SubsetError(RuntimeError):
20
+ """Raised when a selected subset cannot be rendered for test execution."""
21
+
22
+
23
+ def selected_test_selectors(
24
+ response: Mapping[str, Any], *, use_internal_ids: bool = False
25
+ ) -> list[str]:
26
+ selected = response.get("selected")
27
+ if selected is None:
28
+ raise SubsetError("Selection response did not include selected tests.")
29
+ if not isinstance(selected, list):
30
+ raise SubsetError("Selection response selected field was not a list.")
31
+
32
+ selectors: list[str] = []
33
+ for item in selected:
34
+ selector = _selector_from_item(item, use_internal_ids=use_internal_ids)
35
+ if selector:
36
+ selectors.append(selector)
37
+ return selectors
38
+
39
+
40
+ def format_test_selectors(framework: str, selectors: Iterable[str]) -> str:
41
+ values = [str(selector).strip() for selector in selectors if str(selector).strip()]
42
+ if not values:
43
+ raise SubsetError("No tests were selected by Quantik Mind.")
44
+
45
+ if framework in {"pytest", "jest", "rspec", "cucumber", "robot"}:
46
+ return " ".join(values)
47
+ if framework == "maven":
48
+ return f"-Dtest={','.join(values)}"
49
+ if framework == "gradle":
50
+ return " ".join(f"--tests {value}" for value in values)
51
+ if framework == "cypress":
52
+ return f"--spec {','.join(values)}"
53
+ if framework == "playwright":
54
+ return " ".join(values)
55
+ raise SubsetError(f"Unsupported framework for qmind subset: {framework}")
56
+
57
+
58
+ def selection_stats(response: Mapping[str, Any]) -> str | None:
59
+ counts = response.get("counts")
60
+ if not isinstance(counts, Mapping):
61
+ return None
62
+ total = counts.get("total")
63
+ selected = counts.get("selected")
64
+ if total is None or selected is None:
65
+ return None
66
+ return f"Quantik Mind selected {selected}/{total} tests."
67
+
68
+
69
+ def machine_readable_selection_payload(
70
+ response: Mapping[str, Any],
71
+ *,
72
+ selected_tests: Iterable[str],
73
+ ) -> dict[str, Any]:
74
+ payload = dict(response)
75
+ payload.setdefault("selected_tests", list(selected_tests))
76
+
77
+ business_metrics = payload.get("business_metrics")
78
+ if isinstance(business_metrics, Mapping):
79
+ for key in (
80
+ "risk_coverage",
81
+ "top_risk_coverage",
82
+ "residual_risk",
83
+ "risk_efficiency",
84
+ ):
85
+ if key in business_metrics:
86
+ payload.setdefault(key, business_metrics[key])
87
+
88
+ return payload
89
+
90
+
91
+ def _selector_from_item(item: Any, *, use_internal_ids: bool = False) -> str | None:
92
+ if isinstance(item, str):
93
+ return item.strip() or None
94
+ if not isinstance(item, Mapping):
95
+ return None
96
+
97
+ keys = (
98
+ ("internal_test_id", "id", "test_id")
99
+ if use_internal_ids
100
+ else ("test_id", "cli_selector", "test_name")
101
+ )
102
+ for key in keys:
103
+ value = item.get(key)
104
+ if value is not None and str(value).strip():
105
+ return str(value).strip()
106
+ return None