python-qv 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.
qv/core/models.py ADDED
@@ -0,0 +1,132 @@
1
+ """Core diagnostic and scan result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class Severity(str, Enum):
12
+ INFO = "info"
13
+ WARNING = "warning"
14
+ ERROR = "error"
15
+
16
+ @property
17
+ def rank(self) -> int:
18
+ return {"info": 1, "warning": 2, "error": 3}[self.value]
19
+
20
+
21
+ class Evidence(BaseModel):
22
+ """Fact supporting a diagnostic finding."""
23
+
24
+ fact: str
25
+ source: str | None = None
26
+ details: dict[str, Any] = Field(default_factory=dict)
27
+
28
+
29
+ class Suggestion(BaseModel):
30
+ """Safe, actionable remediation recommendation."""
31
+
32
+ description: str
33
+ command: str | None = None
34
+ code_snippet: str | None = None
35
+ is_safe: bool = True
36
+
37
+
38
+ class Diagnostic(BaseModel):
39
+ """Standardized diagnostic finding emitted by all analyzers."""
40
+
41
+ id: str
42
+ severity: Severity
43
+ category: str
44
+ title: str
45
+ message: str
46
+ evidence: list[Evidence] = Field(default_factory=list)
47
+ suggestions: list[Suggestion] = Field(default_factory=list)
48
+ confidence: float = Field(default=1.0, ge=0.0, le=1.0)
49
+ affected_packages: list[str] = Field(default_factory=list)
50
+ file: str | None = None
51
+ line: int | None = None
52
+ column: int | None = None
53
+ dependency_chain: list[str] = Field(default_factory=list)
54
+ doc_url: str | None = None
55
+ metadata: dict[str, Any] = Field(default_factory=dict)
56
+
57
+
58
+ class RootCause(BaseModel):
59
+ """Group of related symptoms belonging to one underlying root cause."""
60
+
61
+ id: str
62
+ title: str
63
+ summary: str
64
+ primary_diagnostic: Diagnostic
65
+ related_diagnostics: list[Diagnostic] = Field(default_factory=list)
66
+ recommendation: Suggestion
67
+
68
+
69
+ class ScanSummary(BaseModel):
70
+ """High-level summary of a scan run."""
71
+
72
+ errors_count: int = 0
73
+ warnings_count: int = 0
74
+ info_count: int = 0
75
+ checks_passed: int = 0
76
+ health_score: int = 100
77
+
78
+
79
+ class ScanResult(BaseModel):
80
+ """Complete output of a qv scan."""
81
+
82
+ project_name: str
83
+ project_path: str
84
+ python_version: str
85
+ package_manager: str
86
+ summary: ScanSummary
87
+ diagnostics: list[Diagnostic] = Field(default_factory=list)
88
+ root_causes: list[RootCause] = Field(default_factory=list)
89
+ metadata: dict[str, Any] = Field(default_factory=dict)
90
+
91
+ @property
92
+ def has_blocking_errors(self) -> bool:
93
+ return self.summary.errors_count > 0
94
+
95
+ @classmethod
96
+ def create(
97
+ cls,
98
+ project_name: str,
99
+ project_path: str,
100
+ python_version: str,
101
+ package_manager: str,
102
+ diagnostics: list[Diagnostic],
103
+ checks_passed: int = 0,
104
+ root_causes: list[RootCause] | None = None,
105
+ metadata: dict[str, Any] | None = None,
106
+ ) -> ScanResult:
107
+ errors = sum(1 for d in diagnostics if d.severity == Severity.ERROR)
108
+ warnings = sum(1 for d in diagnostics if d.severity == Severity.WARNING)
109
+ infos = sum(1 for d in diagnostics if d.severity == Severity.INFO)
110
+
111
+ # Health score calculation (100 base, deductions for errors and warnings)
112
+ penalty = (errors * 15) + (warnings * 5)
113
+ health_score = max(0, min(100, 100 - penalty))
114
+
115
+ summary = ScanSummary(
116
+ errors_count=errors,
117
+ warnings_count=warnings,
118
+ info_count=infos,
119
+ checks_passed=checks_passed,
120
+ health_score=health_score,
121
+ )
122
+
123
+ return cls(
124
+ project_name=project_name,
125
+ project_path=project_path,
126
+ python_version=python_version,
127
+ package_manager=package_manager,
128
+ summary=summary,
129
+ diagnostics=diagnostics,
130
+ root_causes=root_causes or [],
131
+ metadata=metadata or {},
132
+ )
qv/core/project.py ADDED
@@ -0,0 +1,330 @@
1
+ """Project discovery and context builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import importlib.metadata
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from packaging.requirements import Requirement
12
+
13
+ from qv.core.config import QvConfig
14
+ from qv.core.context import (
15
+ CIConfig,
16
+ DependencyDeclaration,
17
+ DockerConfig,
18
+ ImportRecord,
19
+ InstalledDistribution,
20
+ ProjectContext,
21
+ PythonRuntime,
22
+ SourceFile,
23
+ )
24
+
25
+ if sys.version_info >= (3, 11):
26
+ import tomllib
27
+ else:
28
+ import tomli as tomllib
29
+
30
+
31
+ class ProjectDiscovery:
32
+ """Discovers project files, manifests, environment, and source files."""
33
+
34
+ def __init__(self, root: Path, config: QvConfig | None = None) -> None:
35
+ self.root = root.resolve()
36
+ self.config = config or QvConfig.from_pyproject(self.root / "pyproject.toml")
37
+
38
+ def discover_context(self) -> ProjectContext:
39
+ """Build and return an immutable ProjectContext."""
40
+ pyproject_path = self.root / "pyproject.toml"
41
+ project_name = self.root.name
42
+ manifest_files: list[Path] = []
43
+ lock_files: list[Path] = []
44
+ dependencies: list[DependencyDeclaration] = []
45
+
46
+ # 1. Package manager detection & manifest parsing
47
+ package_manager = "pip"
48
+
49
+ if (self.root / "uv.lock").exists():
50
+ package_manager = "uv"
51
+ lock_files.append(self.root / "uv.lock")
52
+ elif (self.root / "poetry.lock").exists():
53
+ package_manager = "poetry"
54
+ lock_files.append(self.root / "poetry.lock")
55
+ elif (self.root / "pdm.lock").exists():
56
+ package_manager = "pdm"
57
+ lock_files.append(self.root / "pdm.lock")
58
+ elif (self.root / "Pipfile.lock").exists():
59
+ package_manager = "pipenv"
60
+ lock_files.append(self.root / "Pipfile.lock")
61
+
62
+ # Check pyproject.toml
63
+ if pyproject_path.exists():
64
+ manifest_files.append(pyproject_path)
65
+ try:
66
+ with open(pyproject_path, "rb") as f:
67
+ pyproject_data = tomllib.load(f)
68
+
69
+ # Name
70
+ if "project" in pyproject_data and "name" in pyproject_data["project"]:
71
+ project_name = pyproject_data["project"]["name"]
72
+
73
+ # Dependencies from [project.dependencies]
74
+ for dep_str in pyproject_data.get("project", {}).get("dependencies", []):
75
+ try:
76
+ req = Requirement(dep_str)
77
+ dependencies.append(
78
+ DependencyDeclaration(
79
+ name=req.name,
80
+ specifier=str(req.specifier),
81
+ source_file=pyproject_path,
82
+ extras=tuple(req.extras),
83
+ )
84
+ )
85
+ except Exception:
86
+ pass
87
+
88
+ # Optional/dev dependencies
89
+ for group, deps in (
90
+ pyproject_data.get("project", {}).get("optional-dependencies", {}).items()
91
+ ):
92
+ is_dev = group in ("dev", "test", "lint")
93
+ for dep_str in deps:
94
+ try:
95
+ req = Requirement(dep_str)
96
+ dependencies.append(
97
+ DependencyDeclaration(
98
+ name=req.name,
99
+ specifier=str(req.specifier),
100
+ source_file=pyproject_path,
101
+ is_dev=is_dev,
102
+ extras=tuple(req.extras),
103
+ )
104
+ )
105
+ except Exception:
106
+ pass
107
+
108
+ # Poetry style [tool.poetry.dependencies]
109
+ poetry_deps = (
110
+ pyproject_data.get("tool", {}).get("poetry", {}).get("dependencies", {})
111
+ )
112
+ for dep_name, dep_val in poetry_deps.items():
113
+ if dep_name.lower() == "python":
114
+ continue
115
+ spec = dep_val if isinstance(dep_val, str) else str(dep_val.get("version", ""))
116
+ dependencies.append(
117
+ DependencyDeclaration(
118
+ name=dep_name,
119
+ specifier=spec,
120
+ source_file=pyproject_path,
121
+ )
122
+ )
123
+ except Exception:
124
+ pass
125
+
126
+ # Check requirements files
127
+ req_candidates = [
128
+ self.root / "requirements.txt",
129
+ self.root / "requirements-dev.txt",
130
+ self.root / "requirements" / "base.txt",
131
+ self.root / "requirements" / "dev.txt",
132
+ self.root / "requirements" / "prod.txt",
133
+ ]
134
+ for req_path in req_candidates:
135
+ if req_path.exists():
136
+ manifest_files.append(req_path)
137
+ is_dev = "dev" in req_path.name.lower()
138
+ try:
139
+ with open(req_path, encoding="utf-8") as f:
140
+ for line_idx, line in enumerate(f, 1):
141
+ line = line.strip()
142
+ if not line or line.startswith("#") or line.startswith("-"):
143
+ continue
144
+ try:
145
+ req = Requirement(line)
146
+ dependencies.append(
147
+ DependencyDeclaration(
148
+ name=req.name,
149
+ specifier=str(req.specifier),
150
+ source_file=req_path,
151
+ line_number=line_idx,
152
+ is_dev=is_dev,
153
+ extras=tuple(req.extras),
154
+ )
155
+ )
156
+ except Exception:
157
+ pass
158
+ except Exception:
159
+ pass
160
+
161
+ # 2. Python runtime detection
162
+ is_venv = hasattr(sys, "real_prefix") or (
163
+ hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
164
+ )
165
+ runtime = PythonRuntime(
166
+ version_str=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
167
+ major=sys.version_info.major,
168
+ minor=sys.version_info.minor,
169
+ micro=sys.version_info.micro,
170
+ executable=sys.executable,
171
+ is_virtualenv=is_venv,
172
+ virtualenv_path=Path(sys.prefix) if is_venv else None,
173
+ )
174
+
175
+ # 3. Installed packages collection
176
+ installed_packages: dict[str, InstalledDistribution] = {}
177
+ try:
178
+ for dist in importlib.metadata.distributions():
179
+ name = dist.metadata["Name"]
180
+ if not name:
181
+ continue
182
+ requires = dist.requires or []
183
+ installed_packages[name.lower()] = InstalledDistribution(
184
+ name=name,
185
+ version=dist.version,
186
+ location=str(dist.locate_file("")),
187
+ requires=tuple(requires),
188
+ )
189
+ except Exception:
190
+ pass
191
+
192
+ # 4. Source file discovery & AST import extraction
193
+ source_files: list[SourceFile] = []
194
+ imports: list[ImportRecord] = []
195
+
196
+ exclude_patterns = set(self.config.paths.exclude)
197
+ for py_path in self.root.rglob("*.py"):
198
+ rel_path = py_path.relative_to(self.root)
199
+ # Check exclusions
200
+ parts = rel_path.parts
201
+ if any(part in exclude_patterns or part.startswith(".") for part in parts):
202
+ continue
203
+
204
+ try:
205
+ content = py_path.read_text(encoding="utf-8")
206
+ is_init = py_path.name == "__init__.py"
207
+ # Compute logical module name
208
+ mod_parts = list(rel_path.with_suffix("").parts)
209
+ if is_init:
210
+ mod_parts = mod_parts[:-1]
211
+ module_name = ".".join(mod_parts)
212
+
213
+ source_file = SourceFile(
214
+ path=py_path,
215
+ relative_path=rel_path,
216
+ content=content,
217
+ is_init=is_init,
218
+ module_name=module_name,
219
+ )
220
+ source_files.append(source_file)
221
+
222
+ # Parse AST
223
+ try:
224
+ tree = ast.parse(content, filename=str(py_path))
225
+ for node in ast.walk(tree):
226
+ if isinstance(node, ast.Import):
227
+ for alias in node.names:
228
+ imports.append(
229
+ ImportRecord(
230
+ module_name=alias.name,
231
+ source_file=py_path,
232
+ line_number=node.lineno,
233
+ is_relative=False,
234
+ )
235
+ )
236
+ elif isinstance(node, ast.ImportFrom):
237
+ mod = node.module or ""
238
+ is_rel = node.level > 0
239
+ symbols = tuple(alias.name for alias in node.names)
240
+ imports.append(
241
+ ImportRecord(
242
+ module_name=mod,
243
+ source_file=py_path,
244
+ line_number=node.lineno,
245
+ is_relative=is_rel,
246
+ imported_symbols=symbols,
247
+ )
248
+ )
249
+ except SyntaxError:
250
+ pass
251
+ except Exception:
252
+ pass
253
+
254
+ # 5. Docker discovery
255
+ docker_config = self._discover_docker()
256
+
257
+ # 6. CI discovery
258
+ ci_config = self._discover_ci()
259
+
260
+ return ProjectContext(
261
+ project_root=self.root,
262
+ project_name=project_name,
263
+ python_runtime=runtime,
264
+ package_manager=package_manager,
265
+ manifest_files=tuple(manifest_files),
266
+ lock_files=tuple(lock_files),
267
+ dependencies=tuple(dependencies),
268
+ installed_packages=installed_packages,
269
+ source_files=tuple(source_files),
270
+ imports=tuple(imports),
271
+ docker=docker_config,
272
+ ci=ci_config,
273
+ )
274
+
275
+ def _discover_docker(self) -> DockerConfig:
276
+ dockerfile = self.root / "Dockerfile"
277
+ if not dockerfile.exists():
278
+ return DockerConfig(has_dockerfile=False)
279
+
280
+ base_image = None
281
+ base_python = None
282
+ try:
283
+ content = dockerfile.read_text(encoding="utf-8")
284
+ from_match = re.search(r"^FROM\s+([^\s]+)", content, re.MULTILINE | re.IGNORECASE)
285
+ if from_match:
286
+ base_image = from_match.group(1)
287
+ py_match = re.search(r"python:?([0-9]+\.[0-9]+)", base_image, re.IGNORECASE)
288
+ if py_match:
289
+ base_python = py_match.group(1)
290
+ except Exception:
291
+ pass
292
+
293
+ return DockerConfig(
294
+ has_dockerfile=True,
295
+ dockerfile_path=dockerfile,
296
+ base_image=base_image,
297
+ base_python_version=base_python,
298
+ )
299
+
300
+ def _discover_ci(self) -> CIConfig:
301
+ gh_workflows = self.root / ".github" / "workflows"
302
+ workflow_files: list[Path] = []
303
+ matrix_versions: list[str] = []
304
+
305
+ if gh_workflows.exists() and gh_workflows.is_dir():
306
+ for f in gh_workflows.glob("*.yml"):
307
+ workflow_files.append(f)
308
+ for f in gh_workflows.glob("*.yaml"):
309
+ workflow_files.append(f)
310
+
311
+ # Look for python-version in workflow files
312
+ for wf in workflow_files:
313
+ try:
314
+ text = wf.read_text(encoding="utf-8")
315
+ found_versions = re.findall(r'["\']?3\.\d+["\']?', text)
316
+ for v in found_versions:
317
+ clean_v = v.strip("\"'")
318
+ if clean_v not in matrix_versions:
319
+ matrix_versions.append(clean_v)
320
+ except Exception:
321
+ pass
322
+
323
+ return CIConfig(
324
+ has_ci=True,
325
+ provider="github_actions",
326
+ workflow_files=tuple(workflow_files),
327
+ matrix_python_versions=tuple(matrix_versions),
328
+ )
329
+
330
+ return CIConfig(has_ci=False)
@@ -0,0 +1 @@
1
+ """Framework plugins."""
@@ -0,0 +1 @@
1
+ """Third-party tool integrations."""
@@ -0,0 +1,13 @@
1
+ """Reporters package."""
2
+
3
+ from qv.reporters.base import Reporter
4
+ from qv.reporters.json_reporter import JsonReporter
5
+ from qv.reporters.sarif import SarifReporter
6
+ from qv.reporters.terminal import TerminalReporter
7
+
8
+ __all__ = [
9
+ "Reporter",
10
+ "TerminalReporter",
11
+ "JsonReporter",
12
+ "SarifReporter",
13
+ ]
qv/reporters/base.py ADDED
@@ -0,0 +1,15 @@
1
+ """Base reporter interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from qv.core.models import ScanResult
8
+
9
+
10
+ class Reporter(Protocol):
11
+ """Protocol for diagnostic output formatters."""
12
+
13
+ def render(self, result: ScanResult) -> str:
14
+ """Render scan result to string output."""
15
+ ...
@@ -0,0 +1,16 @@
1
+ """JSON reporter for machine-readable diagnostic outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from qv.core.models import ScanResult
6
+
7
+
8
+ class JsonReporter:
9
+ """Serializes scan result to formatted JSON string."""
10
+
11
+ def __init__(self, indent: int = 2) -> None:
12
+ self.indent = indent
13
+
14
+ def render(self, result: ScanResult) -> str:
15
+ """Render scan result as JSON."""
16
+ return result.model_dump_json(indent=self.indent)
qv/reporters/sarif.py ADDED
@@ -0,0 +1,85 @@
1
+ """SARIF (Static Analysis Results Interchange Format) reporter conforming to v2.1.0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from qv.core.models import ScanResult, Severity
9
+ from qv.rules.registry import RULES_CATALOG
10
+
11
+
12
+ class SarifReporter:
13
+ """Generates standard SARIF v2.1.0 output for CI / Code Scanning integration."""
14
+
15
+ def render(self, result: ScanResult) -> str:
16
+ """Render scan result to SARIF v2.1.0 JSON format."""
17
+ sarif_data = self.to_sarif_dict(result)
18
+ return json.dumps(sarif_data, indent=2)
19
+
20
+ def to_sarif_dict(self, result: ScanResult) -> dict[str, Any]:
21
+ rules_dict: list[dict[str, Any]] = []
22
+ seen_rules = set()
23
+
24
+ for diag in result.diagnostics:
25
+ if diag.id not in seen_rules:
26
+ seen_rules.add(diag.id)
27
+ rule_def = RULES_CATALOG.get(diag.id)
28
+ rule_entry: dict[str, Any] = {
29
+ "id": diag.id,
30
+ "name": diag.id.replace("-", "_"),
31
+ "shortDescription": {"text": diag.title},
32
+ "fullDescription": {"text": rule_def.description if rule_def else diag.title},
33
+ }
34
+ if diag.doc_url:
35
+ rule_entry["helpUri"] = diag.doc_url
36
+ rules_dict.append(rule_entry)
37
+
38
+ sarif_results: list[dict[str, Any]] = []
39
+ for diag in result.diagnostics:
40
+ level = (
41
+ "error"
42
+ if diag.severity == Severity.ERROR
43
+ else "warning"
44
+ if diag.severity == Severity.WARNING
45
+ else "note"
46
+ )
47
+ res_entry: dict[str, Any] = {
48
+ "ruleId": diag.id,
49
+ "level": level,
50
+ "message": {"text": diag.message},
51
+ }
52
+
53
+ if diag.file:
54
+ res_entry["locations"] = [
55
+ {
56
+ "physicalLocation": {
57
+ "artifactLocation": {
58
+ "uri": diag.file.replace("\\", "/"),
59
+ },
60
+ "region": {
61
+ "startLine": diag.line or 1,
62
+ "startColumn": diag.column or 1,
63
+ },
64
+ }
65
+ }
66
+ ]
67
+ sarif_results.append(res_entry)
68
+
69
+ return {
70
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
71
+ "version": "2.1.0",
72
+ "runs": [
73
+ {
74
+ "tool": {
75
+ "driver": {
76
+ "name": "qv",
77
+ "semanticVersion": "0.1.0",
78
+ "informationUri": "https://github.com/inzamol/qv",
79
+ "rules": rules_dict,
80
+ }
81
+ },
82
+ "results": sarif_results,
83
+ }
84
+ ],
85
+ }