regix 0.1.1__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.
regix/__init__.py ADDED
@@ -0,0 +1,132 @@
1
+ """Regix — Regression Index for Python code quality.
2
+
3
+ Detect, measure and report code quality regressions between git versions
4
+ at function, class and line granularity.
5
+ """
6
+
7
+ from regix.config import RegressionConfig
8
+ from regix.models import (
9
+ CommitMetrics,
10
+ GateCheck,
11
+ GateResult,
12
+ HistoryRegression,
13
+ HistoryReport,
14
+ Improvement,
15
+ MetricDelta,
16
+ Regression,
17
+ RegressionReport,
18
+ Snapshot,
19
+ SymbolMetrics,
20
+ TrendLine,
21
+ )
22
+
23
+ # Ensure backends are registered on import
24
+ from regix.backends import docstring_backend as _docstring # noqa: F401
25
+
26
+ try:
27
+ from regix.backends import lizard_backend as _lizard # noqa: F401
28
+ except ImportError:
29
+ pass
30
+ try:
31
+ from regix.backends import radon_backend as _radon # noqa: F401
32
+ except ImportError:
33
+ pass
34
+ try:
35
+ from regix.backends import coverage_backend as _cov # noqa: F401
36
+ except ImportError:
37
+ pass
38
+ try:
39
+ from regix.backends import vallm_backend as _vallm # noqa: F401
40
+ except ImportError:
41
+ pass
42
+
43
+
44
+ class Regix:
45
+ """Main entry point — wraps snapshot, compare, and history."""
46
+
47
+ def __init__(
48
+ self,
49
+ config: RegressionConfig | str | None = None,
50
+ workdir: str = ".",
51
+ ):
52
+ from pathlib import Path
53
+
54
+ if isinstance(config, str):
55
+ self.config = RegressionConfig.from_file(config)
56
+ elif config is None:
57
+ try:
58
+ self.config = RegressionConfig.from_file(workdir)
59
+ except FileNotFoundError:
60
+ self.config = RegressionConfig()
61
+ else:
62
+ self.config = config
63
+ self.config.workdir = str(Path(workdir).resolve())
64
+ self.config.apply_env_overrides()
65
+
66
+ def snapshot(self, ref: str = "HEAD", use_cache: bool = True) -> Snapshot:
67
+ """Capture metrics at a git ref."""
68
+ from pathlib import Path
69
+ from regix.snapshot import capture
70
+
71
+ return capture(ref, Path(self.config.workdir), self.config)
72
+
73
+ def compare(
74
+ self,
75
+ ref_before: str = "HEAD~1",
76
+ ref_after: str = "HEAD",
77
+ use_cache: bool = True,
78
+ ) -> RegressionReport:
79
+ """Compare metrics between two refs."""
80
+ from regix.compare import compare as do_compare
81
+
82
+ snap_a = self.snapshot(ref_before, use_cache)
83
+ snap_b = self.snapshot(ref_after, use_cache)
84
+ return do_compare(snap_a, snap_b, self.config)
85
+
86
+ def compare_local(self, ref_before: str = "HEAD") -> RegressionReport:
87
+ """Compare a git ref against the current working tree."""
88
+ return self.compare(ref_before, "local", use_cache=False)
89
+
90
+ def history(
91
+ self,
92
+ depth: int = 20,
93
+ ref: str = "HEAD",
94
+ metrics: list[str] | None = None,
95
+ ) -> HistoryReport:
96
+ """Walk commits and return a metric timeline."""
97
+ from pathlib import Path
98
+ from regix.history import build_history
99
+
100
+ return build_history(
101
+ depth=depth, ref=ref,
102
+ workdir=Path(self.config.workdir),
103
+ config=self.config,
104
+ metrics_filter=metrics,
105
+ )
106
+
107
+ def check_gates(self, ref: str = "HEAD") -> GateResult:
108
+ """Check current state against absolute thresholds."""
109
+ from regix.gates import check_gates
110
+
111
+ snap = self.snapshot(ref)
112
+ return check_gates(snap, self.config)
113
+
114
+
115
+ __all__ = [
116
+ "Regix",
117
+ "RegressionConfig",
118
+ "Snapshot",
119
+ "SymbolMetrics",
120
+ "MetricDelta",
121
+ "Regression",
122
+ "Improvement",
123
+ "RegressionReport",
124
+ "CommitMetrics",
125
+ "HistoryRegression",
126
+ "HistoryReport",
127
+ "TrendLine",
128
+ "GateCheck",
129
+ "GateResult",
130
+ ]
131
+
132
+ __version__ = "0.1.1"
@@ -0,0 +1,54 @@
1
+ """Backend ABC and registry for static analysis tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from pathlib import Path
7
+
8
+ from regix.config import RegressionConfig
9
+ from regix.models import SymbolMetrics
10
+
11
+ # ── Global backend registry ─────────────────────────────────────────────────
12
+
13
+ _BACKENDS: dict[str, "BackendBase"] = {}
14
+
15
+
16
+ class BackendBase(ABC):
17
+ """Interface that all analysis backends must implement."""
18
+
19
+ name: str = ""
20
+ required_binary: str | None = None
21
+
22
+ @abstractmethod
23
+ def is_available(self) -> bool:
24
+ """Return True if the backend's dependencies are installed."""
25
+ ...
26
+
27
+ @abstractmethod
28
+ def collect(
29
+ self,
30
+ workdir: Path,
31
+ files: list[Path],
32
+ config: RegressionConfig,
33
+ ) -> list[SymbolMetrics]:
34
+ """Run analysis and return per-symbol metrics."""
35
+ ...
36
+
37
+ def version(self) -> str:
38
+ """Return the version string of the backend tool."""
39
+ return "unknown"
40
+
41
+
42
+ def register_backend(backend: BackendBase) -> None:
43
+ """Register a backend instance for use by Regix."""
44
+ _BACKENDS[backend.name] = backend
45
+
46
+
47
+ def get_backend(name: str) -> BackendBase | None:
48
+ """Look up a registered backend by name."""
49
+ return _BACKENDS.get(name)
50
+
51
+
52
+ def available_backends() -> list[str]:
53
+ """Return names of all registered backends."""
54
+ return list(_BACKENDS.keys())
@@ -0,0 +1,110 @@
1
+ """Coverage backend — reads pytest-cov .coverage data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from regix.backends import BackendBase, register_backend
9
+ from regix.config import RegressionConfig
10
+ from regix.models import SymbolMetrics
11
+
12
+
13
+ class CoverageBackend(BackendBase):
14
+ name = "coverage"
15
+ required_binary = None
16
+
17
+ def is_available(self) -> bool:
18
+ try:
19
+ import coverage as cov # noqa: F401
20
+ return True
21
+ except ImportError:
22
+ return False
23
+
24
+ def version(self) -> str:
25
+ try:
26
+ import coverage
27
+ return coverage.__version__
28
+ except (ImportError, AttributeError):
29
+ return "not installed"
30
+
31
+ def collect(
32
+ self,
33
+ workdir: Path,
34
+ files: list[Path],
35
+ config: RegressionConfig,
36
+ ) -> list[SymbolMetrics]:
37
+ """Read coverage from a JSON report or .coverage file."""
38
+ results: list[SymbolMetrics] = []
39
+
40
+ # Try JSON report first (from pytest --cov-report=json)
41
+ json_candidates = [
42
+ workdir / ".regix" / "coverage.json",
43
+ workdir / "coverage.json",
44
+ workdir / "htmlcov" / "status.json",
45
+ ]
46
+ for cj in json_candidates:
47
+ if cj.exists():
48
+ return self._from_json(cj, files)
49
+
50
+ # Fallback: read .coverage SQLite via coverage API
51
+ cov_file = workdir / ".coverage"
52
+ if cov_file.exists():
53
+ return self._from_coverage_file(cov_file, files, workdir)
54
+
55
+ return results
56
+
57
+ def _from_json(
58
+ self, path: Path, files: list[Path]
59
+ ) -> list[SymbolMetrics]:
60
+ results: list[SymbolMetrics] = []
61
+ try:
62
+ data = json.loads(path.read_text(encoding="utf-8"))
63
+ except (json.JSONDecodeError, OSError):
64
+ return results
65
+
66
+ file_data = data.get("files", {})
67
+ file_set = {str(f) for f in files}
68
+ for fname, finfo in file_data.items():
69
+ if file_set and fname not in file_set:
70
+ continue
71
+ summary = finfo.get("summary", {})
72
+ pct = summary.get("percent_covered", None)
73
+ if pct is not None:
74
+ results.append(
75
+ SymbolMetrics(
76
+ file=fname,
77
+ symbol=None,
78
+ coverage=pct,
79
+ raw={"covered_lines": summary.get("covered_lines", 0)},
80
+ )
81
+ )
82
+ return results
83
+
84
+ def _from_coverage_file(
85
+ self, cov_path: Path, files: list[Path], workdir: Path
86
+ ) -> list[SymbolMetrics]:
87
+ results: list[SymbolMetrics] = []
88
+ try:
89
+ import coverage as cov_lib
90
+
91
+ cov = cov_lib.Coverage(data_file=str(cov_path))
92
+ cov.load()
93
+ data = cov.get_data()
94
+ for fname in data.measured_files():
95
+ lines = data.lines(fname) or []
96
+ missing = data.lines(fname) # simplified
97
+ total = len(lines)
98
+ if total > 0:
99
+ pct = (len(lines) / total) * 100
100
+ else:
101
+ pct = 0.0
102
+ results.append(
103
+ SymbolMetrics(file=fname, symbol=None, coverage=pct)
104
+ )
105
+ except Exception:
106
+ pass
107
+ return results
108
+
109
+
110
+ register_backend(CoverageBackend())
@@ -0,0 +1,66 @@
1
+ """Docstring backend — pure Python, ast-based docstring coverage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from regix.backends import BackendBase, register_backend
9
+ from regix.config import RegressionConfig
10
+ from regix.models import SymbolMetrics
11
+
12
+
13
+ class DocstringBackend(BackendBase):
14
+ name = "docstring"
15
+ required_binary = None
16
+
17
+ def is_available(self) -> bool:
18
+ return True # Built-in, always available
19
+
20
+ def version(self) -> str:
21
+ import sys
22
+ return f"ast (Python {sys.version_info.major}.{sys.version_info.minor})"
23
+
24
+ def collect(
25
+ self,
26
+ workdir: Path,
27
+ files: list[Path],
28
+ config: RegressionConfig,
29
+ ) -> list[SymbolMetrics]:
30
+ results: list[SymbolMetrics] = []
31
+ for fpath in files:
32
+ full = workdir / fpath
33
+ if not full.exists() or full.suffix != ".py":
34
+ continue
35
+ try:
36
+ source = full.read_text(encoding="utf-8")
37
+ tree = ast.parse(source, filename=str(full))
38
+ except (SyntaxError, UnicodeDecodeError, OSError):
39
+ continue
40
+
41
+ total = 0
42
+ documented = 0
43
+ for node in ast.walk(tree):
44
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
45
+ # Skip private/dunder unless it's __init__
46
+ name = node.name
47
+ if name.startswith("_") and name != "__init__":
48
+ continue
49
+ total += 1
50
+ docstring = ast.get_docstring(node)
51
+ if docstring:
52
+ documented += 1
53
+
54
+ pct = (documented / total * 100) if total > 0 else 100.0
55
+ results.append(
56
+ SymbolMetrics(
57
+ file=str(fpath),
58
+ symbol=None,
59
+ docstring_coverage=round(pct, 1),
60
+ raw={"total_public": total, "documented": documented},
61
+ )
62
+ )
63
+ return results
64
+
65
+
66
+ register_backend(DocstringBackend())
@@ -0,0 +1,71 @@
1
+ """Lizard backend — cyclomatic complexity and function length."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from regix.backends import BackendBase, register_backend
9
+ from regix.config import RegressionConfig
10
+ from regix.models import SymbolMetrics
11
+
12
+
13
+ class LizardBackend(BackendBase):
14
+ name = "lizard"
15
+ required_binary = "lizard"
16
+
17
+ def is_available(self) -> bool:
18
+ try:
19
+ import lizard # noqa: F401
20
+ return True
21
+ except ImportError:
22
+ return False
23
+
24
+ def version(self) -> str:
25
+ try:
26
+ import lizard
27
+ return getattr(lizard, "__version__", "unknown")
28
+ except ImportError:
29
+ return "not installed"
30
+
31
+ def collect(
32
+ self,
33
+ workdir: Path,
34
+ files: list[Path],
35
+ config: RegressionConfig,
36
+ ) -> list[SymbolMetrics]:
37
+ try:
38
+ import lizard
39
+ except ImportError:
40
+ return []
41
+
42
+ results: list[SymbolMetrics] = []
43
+ for fpath in files:
44
+ full = workdir / fpath
45
+ if not full.exists() or not full.is_file():
46
+ continue
47
+ try:
48
+ analysis = lizard.analyze_file(str(full))
49
+ except Exception:
50
+ continue
51
+
52
+ for func in analysis.function_list:
53
+ results.append(
54
+ SymbolMetrics(
55
+ file=str(fpath),
56
+ symbol=func.name,
57
+ line_start=func.start_line,
58
+ line_end=func.end_line,
59
+ cc=func.cyclomatic_complexity,
60
+ length=func.nloc,
61
+ raw={
62
+ "token_count": func.token_count,
63
+ "parameter_count": len(func.parameters),
64
+ },
65
+ )
66
+ )
67
+ return results
68
+
69
+
70
+ # Auto-register
71
+ register_backend(LizardBackend())
@@ -0,0 +1,92 @@
1
+ """Radon backend — maintainability index and raw CC."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from regix.backends import BackendBase, register_backend
8
+ from regix.config import RegressionConfig
9
+ from regix.models import SymbolMetrics
10
+
11
+
12
+ class RadonBackend(BackendBase):
13
+ name = "radon"
14
+ required_binary = None
15
+
16
+ def is_available(self) -> bool:
17
+ try:
18
+ import radon.complexity # noqa: F401
19
+ import radon.metrics # noqa: F401
20
+ return True
21
+ except ImportError:
22
+ return False
23
+
24
+ def version(self) -> str:
25
+ try:
26
+ import radon
27
+ return getattr(radon, "__version__", "unknown")
28
+ except ImportError:
29
+ return "not installed"
30
+
31
+ def collect(
32
+ self,
33
+ workdir: Path,
34
+ files: list[Path],
35
+ config: RegressionConfig,
36
+ ) -> list[SymbolMetrics]:
37
+ try:
38
+ from radon.complexity import cc_visit
39
+ from radon.metrics import mi_visit
40
+ except ImportError:
41
+ return []
42
+
43
+ results: list[SymbolMetrics] = []
44
+ for fpath in files:
45
+ full = workdir / fpath
46
+ if not full.exists() or full.suffix != ".py":
47
+ continue
48
+ try:
49
+ source = full.read_text(encoding="utf-8")
50
+ except (OSError, UnicodeDecodeError):
51
+ continue
52
+
53
+ # Maintainability index (module-level)
54
+ try:
55
+ mi = mi_visit(source, multi=True)
56
+ except Exception:
57
+ mi = None
58
+
59
+ # CC per function/class
60
+ try:
61
+ cc_results = cc_visit(source)
62
+ except Exception:
63
+ cc_results = []
64
+
65
+ # Module-level entry with MI
66
+ results.append(
67
+ SymbolMetrics(
68
+ file=str(fpath),
69
+ symbol=None,
70
+ mi=mi,
71
+ raw={"radon_mi": mi},
72
+ )
73
+ )
74
+
75
+ for block in cc_results:
76
+ results.append(
77
+ SymbolMetrics(
78
+ file=str(fpath),
79
+ symbol=block.name,
80
+ line_start=block.lineno,
81
+ line_end=block.endline,
82
+ cc=block.complexity,
83
+ raw={
84
+ "radon_rank": block.letter,
85
+ "radon_classname": getattr(block, "classname", None),
86
+ },
87
+ )
88
+ )
89
+ return results
90
+
91
+
92
+ register_backend(RadonBackend())
@@ -0,0 +1,74 @@
1
+ """Vallm backend — LLM-based code quality scoring via vallm."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ from regix.backends import BackendBase, register_backend
11
+ from regix.config import RegressionConfig
12
+ from regix.models import SymbolMetrics
13
+
14
+
15
+ class VallmBackend(BackendBase):
16
+ name = "vallm"
17
+ required_binary = "vallm"
18
+
19
+ def is_available(self) -> bool:
20
+ return shutil.which("vallm") is not None
21
+
22
+ def version(self) -> str:
23
+ try:
24
+ result = subprocess.run(
25
+ ["vallm", "--version"],
26
+ capture_output=True, text=True, check=False,
27
+ )
28
+ return result.stdout.strip() or "unknown"
29
+ except FileNotFoundError:
30
+ return "not installed"
31
+
32
+ def collect(
33
+ self,
34
+ workdir: Path,
35
+ files: list[Path],
36
+ config: RegressionConfig,
37
+ ) -> list[SymbolMetrics]:
38
+ if not self.is_available():
39
+ return []
40
+
41
+ results: list[SymbolMetrics] = []
42
+ try:
43
+ proc = subprocess.run(
44
+ ["vallm", "batch", str(workdir), "--recursive", "--format", "json"],
45
+ capture_output=True, text=True, check=False,
46
+ cwd=str(workdir),
47
+ )
48
+ if proc.returncode != 0:
49
+ return results
50
+
51
+ data = json.loads(proc.stdout)
52
+ file_set = {str(f) for f in files} if files else set()
53
+
54
+ for entry in data if isinstance(data, list) else data.get("files", []):
55
+ fname = entry.get("file", "")
56
+ if file_set and fname not in file_set:
57
+ continue
58
+ score = entry.get("score", entry.get("quality_score"))
59
+ if score is not None:
60
+ results.append(
61
+ SymbolMetrics(
62
+ file=fname,
63
+ symbol=None,
64
+ quality_score=float(score),
65
+ raw=entry,
66
+ )
67
+ )
68
+ except (json.JSONDecodeError, OSError, FileNotFoundError):
69
+ pass
70
+
71
+ return results
72
+
73
+
74
+ register_backend(VallmBackend())
regix/cache.py ADDED
@@ -0,0 +1,97 @@
1
+ """Content-addressed snapshot cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gzip
6
+ import hashlib
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from regix.models import Snapshot
11
+
12
+
13
+ def _cache_dir(config_dir: str = "~/.cache/regix") -> Path:
14
+ """Resolve cache directory (XDG-compliant)."""
15
+ import os
16
+ xdg = os.environ.get("XDG_CACHE_HOME")
17
+ if xdg:
18
+ d = Path(xdg) / "regix"
19
+ else:
20
+ d = Path(config_dir).expanduser()
21
+ d.mkdir(parents=True, exist_ok=True)
22
+ return d
23
+
24
+
25
+ def _cache_key(commit_sha: str, backend_versions: dict[str, str]) -> str:
26
+ """Compute cache key from commit SHA and backend versions."""
27
+ raw = commit_sha + ":" + json.dumps(sorted(backend_versions.items()))
28
+ return hashlib.sha256(raw.encode()).hexdigest()[:24]
29
+
30
+
31
+ def lookup(
32
+ commit_sha: str,
33
+ backend_versions: dict[str, str],
34
+ cache_dir: str = "~/.cache/regix",
35
+ ) -> Snapshot | None:
36
+ """Return cached snapshot or None."""
37
+ d = _cache_dir(cache_dir)
38
+ key = _cache_key(commit_sha, backend_versions)
39
+ path = d / f"{key}.json.gz"
40
+ if not path.exists():
41
+ return None
42
+ try:
43
+ raw = gzip.decompress(path.read_bytes()).decode("utf-8")
44
+ data = json.loads(raw)
45
+ return Snapshot.load.__func__.__code__ # type: ignore[attr-defined]
46
+ # Simplified: reconstruct from stored JSON
47
+ except Exception:
48
+ return None
49
+ return None
50
+
51
+
52
+ def store(
53
+ snapshot: Snapshot,
54
+ cache_dir: str = "~/.cache/regix",
55
+ ) -> Path:
56
+ """Store a snapshot in the cache, return its path."""
57
+ d = _cache_dir(cache_dir)
58
+ if not snapshot.commit_sha:
59
+ raise ValueError("Cannot cache a snapshot without a commit SHA (local ref)")
60
+ key = _cache_key(snapshot.commit_sha, snapshot.backend_versions)
61
+ path = d / f"{key}.json.gz"
62
+ data = json.dumps({
63
+ "ref": snapshot.ref,
64
+ "commit_sha": snapshot.commit_sha,
65
+ "timestamp": snapshot.timestamp.isoformat(),
66
+ "workdir": str(snapshot.workdir),
67
+ "backend_versions": snapshot.backend_versions,
68
+ "symbols": [
69
+ {
70
+ "file": s.file,
71
+ "symbol": s.symbol,
72
+ "line_start": s.line_start,
73
+ "line_end": s.line_end,
74
+ "cc": s.cc,
75
+ "mi": s.mi,
76
+ "length": s.length,
77
+ "coverage": s.coverage,
78
+ "docstring_coverage": s.docstring_coverage,
79
+ "quality_score": s.quality_score,
80
+ "imports": s.imports,
81
+ "raw": s.raw,
82
+ }
83
+ for s in snapshot.symbols
84
+ ],
85
+ }, default=str)
86
+ path.write_bytes(gzip.compress(data.encode("utf-8")))
87
+ return path
88
+
89
+
90
+ def clear(cache_dir: str = "~/.cache/regix") -> int:
91
+ """Remove all cached snapshots. Returns count removed."""
92
+ d = _cache_dir(cache_dir)
93
+ count = 0
94
+ for f in d.glob("*.json.gz"):
95
+ f.unlink()
96
+ count += 1
97
+ return count