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/cli/main.py ADDED
@@ -0,0 +1,253 @@
1
+ """qv CLI entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+
12
+ from qv import __version__
13
+ from qv.analyzers.dependencies.analyzer import DependencyAnalyzer
14
+ from qv.analyzers.environment.drift import EnvironmentAnalyzer
15
+ from qv.analyzers.imports.analyzer import ImportAnalyzer
16
+ from qv.core.config import QvConfig
17
+ from qv.core.engine import AnalysisEngine
18
+ from qv.core.models import Severity
19
+ from qv.core.project import ProjectDiscovery
20
+ from qv.reporters.json_reporter import JsonReporter
21
+ from qv.reporters.sarif import SarifReporter
22
+ from qv.reporters.terminal import TerminalReporter
23
+ from qv.rules.registry import RULES_CATALOG, get_rule_definition
24
+
25
+ if sys.platform == "win32":
26
+ try:
27
+ if sys.stdout and hasattr(sys.stdout, "reconfigure"):
28
+ sys.stdout.reconfigure(encoding="utf-8")
29
+ if sys.stderr and hasattr(sys.stderr, "reconfigure"):
30
+ sys.stderr.reconfigure(encoding="utf-8")
31
+ except Exception:
32
+ pass
33
+
34
+ console = Console()
35
+
36
+
37
+ @click.group(invoke_without_command=False)
38
+ @click.version_option(version=__version__, prog_name="qv")
39
+ def cli() -> None:
40
+ """qv - Diagnose why a Python project is unhealthy."""
41
+ pass
42
+
43
+
44
+ @cli.command("scan")
45
+ @click.argument(
46
+ "path",
47
+ default=".",
48
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
49
+ )
50
+ @click.option("--strict", is_flag=True, help="Promote warnings to errors (fails CI on warnings).")
51
+ @click.option("--ci", "ci_mode", is_flag=True, help="Run in CI mode with non-interactive output.")
52
+ @click.option("--json", "as_json", is_flag=True, help="Output diagnostics in JSON format.")
53
+ @click.option(
54
+ "--sarif", "as_sarif", is_flag=True, help="Output diagnostics in SARIF v2.1.0 format."
55
+ )
56
+ @click.option(
57
+ "--output", "-o", type=click.Path(dir_okay=False, path_type=Path), help="Write output to file."
58
+ )
59
+ def scan(
60
+ path: Path,
61
+ strict: bool,
62
+ ci_mode: bool,
63
+ as_json: bool,
64
+ as_sarif: bool,
65
+ output: Path | None,
66
+ ) -> None:
67
+ """Scan a Python project and report health findings."""
68
+ try:
69
+ pyproject_path = path / "pyproject.toml"
70
+ config = QvConfig.from_pyproject(pyproject_path if pyproject_path.exists() else None)
71
+ if strict or ci_mode:
72
+ config.strict = True
73
+
74
+ discovery = ProjectDiscovery(root=path, config=config)
75
+ context = discovery.discover_context()
76
+
77
+ engine = AnalysisEngine(config=config)
78
+ result = engine.run(context)
79
+
80
+ # Select reporter
81
+ if as_sarif:
82
+ reporter = SarifReporter()
83
+ rendered = reporter.render(result)
84
+ elif as_json:
85
+ reporter = JsonReporter()
86
+ rendered = reporter.render(result)
87
+ else:
88
+ reporter = TerminalReporter(console=console)
89
+ rendered = None
90
+
91
+ if output:
92
+ content_to_write = (
93
+ rendered if rendered is not None else TerminalReporter().render(result)
94
+ )
95
+ output.write_text(content_to_write, encoding="utf-8")
96
+ console.print(f"[green]Report successfully written to {output}[/green]")
97
+ elif rendered is not None:
98
+ click.echo(rendered)
99
+ else:
100
+ TerminalReporter(console=console).print_result(result)
101
+
102
+ # Determine exit code
103
+ if result.has_blocking_errors:
104
+ sys.exit(1)
105
+ if (strict or ci_mode) and result.summary.warnings_count > 0:
106
+ sys.exit(1)
107
+ sys.exit(0)
108
+
109
+ except click.ClickException:
110
+ raise
111
+ except SystemExit:
112
+ raise
113
+ except Exception as e:
114
+ console.print(f"[bold red]Analysis failed:[/bold red] {e}")
115
+ sys.exit(3)
116
+
117
+
118
+ @cli.command("explain")
119
+ @click.argument("rule_id", type=str)
120
+ def explain(rule_id: str) -> None:
121
+ """Explain a specific rule and its remediation strategies."""
122
+ rule_def = get_rule_definition(rule_id)
123
+ if not rule_def:
124
+ console.print(f"[bold red]Error:[/bold red] Unknown rule ID '{rule_id}'.")
125
+ console.print("Use one of: " + ", ".join(RULES_CATALOG.keys()))
126
+ sys.exit(2)
127
+
128
+ color = "red" if rule_def.default_severity == Severity.ERROR else "yellow"
129
+ panel_content = (
130
+ f"[bold]Category:[/bold] {rule_def.category.title()}\n"
131
+ f"[bold]Default Severity:[/bold] [{color}]{rule_def.default_severity.value.upper()}[/{color}]\n\n"
132
+ f"[bold]Description:[/bold]\n{rule_def.description}\n\n"
133
+ f"[bold cyan]Remediation Recommendation:[/bold cyan]\n{rule_def.remediation_hint}\n\n"
134
+ f"[bold dim]Documentation:[/bold dim]\n{rule_def.doc_url or 'N/A'}"
135
+ )
136
+ panel = Panel(
137
+ panel_content,
138
+ title=f"[bold]{rule_def.id} — {rule_def.title}[/bold]",
139
+ border_style="cyan",
140
+ padding=(1, 2),
141
+ )
142
+ console.print(panel)
143
+
144
+
145
+ @cli.command("init")
146
+ @click.argument(
147
+ "path",
148
+ default=".",
149
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
150
+ )
151
+ def init_cmd(path: Path) -> None:
152
+ """Initialize qv configuration in pyproject.toml without overwriting existing settings."""
153
+ pyproject_path = path / "pyproject.toml"
154
+ default_section = """
155
+ [tool.qv]
156
+ [tool.qv.rules]
157
+ DEP-001 = "error"
158
+ DEP-002 = "error"
159
+ DEP-003 = "warning"
160
+ DEP-004 = "warning"
161
+ DEP-005 = "warning"
162
+ IMP-001 = "error"
163
+ IMP-002 = "error"
164
+
165
+ [tool.qv.paths]
166
+ exclude = [
167
+ ".venv",
168
+ "build",
169
+ "dist",
170
+ "node_modules",
171
+ ]
172
+ """
173
+ if not pyproject_path.exists():
174
+ pyproject_path.write_text(default_section.lstrip(), encoding="utf-8")
175
+ console.print(
176
+ f"[green]Created {pyproject_path} with default [tool.qv] configuration.[/green]"
177
+ )
178
+ return
179
+
180
+ content = pyproject_path.read_text(encoding="utf-8")
181
+ if "[tool.qv]" in content or "[tool.pydoctor]" in content:
182
+ console.print(
183
+ "[yellow]pyproject.toml already contains [tool.qv] configuration. Skipping.[/yellow]"
184
+ )
185
+ return
186
+
187
+ # Append to existing
188
+ updated = content.rstrip() + "\n" + default_section
189
+ pyproject_path.write_text(updated, encoding="utf-8")
190
+ console.print(f"[green]Added [tool.qv] configuration to {pyproject_path}.[/green]")
191
+
192
+
193
+ @cli.command("dependency")
194
+ @click.argument(
195
+ "path",
196
+ default=".",
197
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
198
+ )
199
+ def dependency_cmd(path: Path) -> None:
200
+ """Run dependency-focused checks only."""
201
+ discovery = ProjectDiscovery(root=path)
202
+ context = discovery.discover_context()
203
+ engine = AnalysisEngine(analyzers=[DependencyAnalyzer()])
204
+ result = engine.run(context)
205
+ TerminalReporter(console=console).print_result(result)
206
+ sys.exit(1 if result.has_blocking_errors else 0)
207
+
208
+
209
+ @cli.command("environment")
210
+ @click.argument(
211
+ "path",
212
+ default=".",
213
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
214
+ )
215
+ def environment_cmd(path: Path) -> None:
216
+ """Run environment and runtime drift checks only."""
217
+ discovery = ProjectDiscovery(root=path)
218
+ context = discovery.discover_context()
219
+ engine = AnalysisEngine(analyzers=[EnvironmentAnalyzer()])
220
+ result = engine.run(context)
221
+ TerminalReporter(console=console).print_result(result)
222
+ sys.exit(1 if result.has_blocking_errors else 0)
223
+
224
+
225
+ @cli.command("architecture")
226
+ @click.argument(
227
+ "path",
228
+ default=".",
229
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
230
+ )
231
+ def architecture_cmd(path: Path) -> None:
232
+ """Run AST and import architecture checks only."""
233
+ discovery = ProjectDiscovery(root=path)
234
+ context = discovery.discover_context()
235
+ engine = AnalysisEngine(analyzers=[ImportAnalyzer()])
236
+ result = engine.run(context)
237
+ TerminalReporter(console=console).print_result(result)
238
+ sys.exit(1 if result.has_blocking_errors else 0)
239
+
240
+
241
+ @cli.command("version")
242
+ def version_cmd() -> None:
243
+ """Print qv version."""
244
+ console.print(f"qv v{__version__}")
245
+
246
+
247
+ def main() -> None:
248
+ """Main entrypoint for setuptools / script runner."""
249
+ cli()
250
+
251
+
252
+ if __name__ == "__main__":
253
+ main()
qv/core/analyzer.py ADDED
@@ -0,0 +1,21 @@
1
+ """Analyzer protocol and base classes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol, runtime_checkable
6
+
7
+ from qv.core.context import ProjectContext
8
+ from qv.core.models import Diagnostic
9
+
10
+
11
+ @runtime_checkable
12
+ class Analyzer(Protocol):
13
+ """Protocol for all diagnostic analyzers."""
14
+
15
+ id: str
16
+ name: str
17
+ description: str
18
+
19
+ def analyze(self, context: ProjectContext) -> list[Diagnostic]:
20
+ """Analyze project context and return list of diagnostics."""
21
+ ...
qv/core/config.py ADDED
@@ -0,0 +1,122 @@
1
+ """Configuration loader and validator for qv."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ if sys.version_info >= (3, 11):
11
+ import tomllib
12
+ else:
13
+ import tomli as tomllib
14
+
15
+ from qv.core.models import Severity
16
+
17
+
18
+ @dataclass
19
+ class RuleConfig:
20
+ """Per-rule configuration."""
21
+
22
+ severity: Severity | None = None # None means use rule default
23
+ disabled: bool = False
24
+
25
+
26
+ @dataclass
27
+ class PathConfig:
28
+ """Path matching configuration."""
29
+
30
+ exclude: list[str] = field(
31
+ default_factory=lambda: [
32
+ ".venv",
33
+ "venv",
34
+ "env",
35
+ ".env",
36
+ "build",
37
+ "dist",
38
+ "node_modules",
39
+ "__pycache__",
40
+ ".pytest_cache",
41
+ ".git",
42
+ ]
43
+ )
44
+ include: list[str] = field(default_factory=list)
45
+
46
+
47
+ @dataclass
48
+ class QvConfig:
49
+ """Top-level configuration for qv."""
50
+
51
+ rules: dict[str, RuleConfig] = field(default_factory=dict)
52
+ ignored_rules: set[str] = field(default_factory=set)
53
+ paths: PathConfig = field(default_factory=PathConfig)
54
+ target_python: str | None = None
55
+ strict: bool = False
56
+
57
+ def is_rule_enabled(self, rule_id: str) -> bool:
58
+ if rule_id in self.ignored_rules:
59
+ return False
60
+ if rule_id in self.rules and self.rules[rule_id].disabled:
61
+ return False
62
+ return True
63
+
64
+ def get_effective_severity(self, rule_id: str, default_severity: Severity) -> Severity:
65
+ if rule_id in self.rules and self.rules[rule_id].severity is not None:
66
+ sev = self.rules[rule_id].severity
67
+ assert sev is not None
68
+ if self.strict and sev == Severity.WARNING:
69
+ return Severity.ERROR
70
+ return sev
71
+ if self.strict and default_severity == Severity.WARNING:
72
+ return Severity.ERROR
73
+ return default_severity
74
+
75
+ @classmethod
76
+ def from_pyproject(cls, pyproject_path: Path | None = None) -> QvConfig:
77
+ """Load configuration from pyproject.toml if present."""
78
+ if pyproject_path is None or not pyproject_path.exists():
79
+ return cls()
80
+
81
+ try:
82
+ with open(pyproject_path, "rb") as f:
83
+ data = tomllib.load(f)
84
+ except Exception:
85
+ return cls()
86
+
87
+ # Support [tool.qv] as primary and [tool.pydoctor] as fallback
88
+ tool_config: dict[str, Any] = data.get("tool", {}).get("qv") or data.get("tool", {}).get(
89
+ "pydoctor", {}
90
+ )
91
+ if not tool_config:
92
+ return cls()
93
+
94
+ rules: dict[str, RuleConfig] = {}
95
+ for rule_id, sev_str in tool_config.get("rules", {}).items():
96
+ sev_str_lower = str(sev_str).lower()
97
+ if sev_str_lower == "off":
98
+ rules[rule_id] = RuleConfig(disabled=True)
99
+ elif sev_str_lower in ("error", "warning", "info"):
100
+ rules[rule_id] = RuleConfig(severity=Severity(sev_str_lower))
101
+
102
+ ignored_rules = set(tool_config.get("ignore", {}).get("rules", []))
103
+
104
+ paths_dict = tool_config.get("paths", {})
105
+ paths = PathConfig(
106
+ exclude=paths_dict.get("exclude", PathConfig().exclude),
107
+ include=paths_dict.get("include", []),
108
+ )
109
+
110
+ runtime_dict = tool_config.get("runtime", {})
111
+ target_python = runtime_dict.get("python")
112
+
113
+ return cls(
114
+ rules=rules,
115
+ ignored_rules=ignored_rules,
116
+ paths=paths,
117
+ target_python=target_python,
118
+ )
119
+
120
+
121
+ # Backward compatibility alias
122
+ PyDoctorConfig = QvConfig
qv/core/context.py ADDED
@@ -0,0 +1,105 @@
1
+ """Immutable project context consumed by analyzers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PythonRuntime:
12
+ """Information about detected/configured Python runtime."""
13
+
14
+ version_str: str
15
+ major: int
16
+ minor: int
17
+ micro: int
18
+ executable: str | None = None
19
+ is_virtualenv: bool = False
20
+ virtualenv_path: Path | None = None
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class DependencyDeclaration:
25
+ """A declared project dependency (e.g. from pyproject.toml or requirements.txt)."""
26
+
27
+ name: str
28
+ specifier: str
29
+ source_file: Path
30
+ line_number: int | None = None
31
+ is_dev: bool = False
32
+ extras: tuple[str, ...] = ()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class InstalledDistribution:
37
+ """An installed Python package in the environment."""
38
+
39
+ name: str
40
+ version: str
41
+ location: str | None = None
42
+ requires: tuple[str, ...] = ()
43
+ required_by: tuple[str, ...] = ()
44
+ direct_url: str | None = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ImportRecord:
49
+ """An import statement discovered in source code."""
50
+
51
+ module_name: str
52
+ source_file: Path
53
+ line_number: int
54
+ is_relative: bool
55
+ imported_symbols: tuple[str, ...] = ()
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SourceFile:
60
+ """A Python source file in the project."""
61
+
62
+ path: Path
63
+ relative_path: Path
64
+ content: str
65
+ is_init: bool = False
66
+ module_name: str = ""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class DockerConfig:
71
+ """Detected Docker configuration."""
72
+
73
+ has_dockerfile: bool
74
+ dockerfile_path: Path | None = None
75
+ base_image: str | None = None
76
+ base_python_version: str | None = None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class CIConfig:
81
+ """Detected CI configuration."""
82
+
83
+ has_ci: bool
84
+ provider: str | None = None # github_actions, gitlab_ci, etc.
85
+ workflow_files: tuple[Path, ...] = ()
86
+ matrix_python_versions: tuple[str, ...] = ()
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ProjectContext:
91
+ """Immutable project context passed into each analyzer."""
92
+
93
+ project_root: Path
94
+ project_name: str
95
+ python_runtime: PythonRuntime
96
+ package_manager: str # uv, poetry, pip, pdm, flit, hatch, etc.
97
+ manifest_files: tuple[Path, ...]
98
+ lock_files: tuple[Path, ...]
99
+ dependencies: tuple[DependencyDeclaration, ...]
100
+ installed_packages: dict[str, InstalledDistribution]
101
+ source_files: tuple[SourceFile, ...]
102
+ imports: tuple[ImportRecord, ...]
103
+ docker: DockerConfig
104
+ ci: CIConfig
105
+ config: dict[str, Any] = field(default_factory=dict)
qv/core/engine.py ADDED
@@ -0,0 +1,116 @@
1
+ """Analysis engine coordinating analyzer execution and root cause grouping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from qv.analyzers.dependencies.analyzer import DependencyAnalyzer
8
+ from qv.analyzers.environment.drift import EnvironmentAnalyzer
9
+ from qv.analyzers.imports.analyzer import ImportAnalyzer
10
+ from qv.analyzers.packaging.analyzer import PackagingAnalyzer
11
+ from qv.core.analyzer import Analyzer
12
+ from qv.core.config import QvConfig
13
+ from qv.core.context import ProjectContext
14
+ from qv.core.models import Diagnostic, RootCause, ScanResult, Suggestion
15
+
16
+
17
+ class AnalysisEngine:
18
+ """Coordinates execution of analyzers, applies config, and correlates root causes."""
19
+
20
+ def __init__(
21
+ self,
22
+ config: QvConfig | None = None,
23
+ analyzers: Sequence[Analyzer] | None = None,
24
+ ) -> None:
25
+ self.config = config or QvConfig()
26
+ if analyzers is not None:
27
+ self.analyzers = list(analyzers)
28
+ else:
29
+ self.analyzers = [
30
+ PackagingAnalyzer(),
31
+ EnvironmentAnalyzer(),
32
+ DependencyAnalyzer(),
33
+ ImportAnalyzer(),
34
+ ]
35
+
36
+ def run(self, context: ProjectContext) -> ScanResult:
37
+ """Run all registered analyzers against the given ProjectContext."""
38
+ raw_diagnostics: list[Diagnostic] = []
39
+ checks_evaluated = 0
40
+
41
+ for analyzer in self.analyzers:
42
+ checks_evaluated += 1
43
+ try:
44
+ findings = analyzer.analyze(context)
45
+ raw_diagnostics.extend(findings)
46
+ except Exception:
47
+ # Keep engine resilient
48
+ pass
49
+
50
+ # Apply configuration (filtering and severity overrides)
51
+ filtered_diagnostics: list[Diagnostic] = []
52
+ for diag in raw_diagnostics:
53
+ if not self.config.is_rule_enabled(diag.id):
54
+ continue
55
+
56
+ effective_sev = self.config.get_effective_severity(diag.id, diag.severity)
57
+ updated_diag = diag.model_copy(update={"severity": effective_sev})
58
+ filtered_diagnostics.append(updated_diag)
59
+
60
+ # Correlate root causes
61
+ root_causes = self._correlate_root_causes(filtered_diagnostics)
62
+
63
+ # Calculate passed checks approximation
64
+ passed_count = max(0, 30 + checks_evaluated * 5 - len(filtered_diagnostics))
65
+
66
+ return ScanResult.create(
67
+ project_name=context.project_name,
68
+ project_path=str(context.project_root),
69
+ python_version=context.python_runtime.version_str,
70
+ package_manager=context.package_manager,
71
+ diagnostics=filtered_diagnostics,
72
+ checks_passed=passed_count,
73
+ root_causes=root_causes,
74
+ )
75
+
76
+ def _correlate_root_causes(self, diagnostics: list[Diagnostic]) -> list[RootCause]:
77
+ """Group related diagnostics by affected packages or rule categories."""
78
+ root_causes: list[RootCause] = []
79
+ package_groups: dict[str, list[Diagnostic]] = {}
80
+
81
+ for diag in diagnostics:
82
+ if diag.affected_packages:
83
+ for pkg in diag.affected_packages:
84
+ package_groups.setdefault(pkg.lower(), []).append(diag)
85
+
86
+ seen_diags: set[str] = set()
87
+ rc_counter = 1
88
+
89
+ for pkg, group in package_groups.items():
90
+ if len(group) > 1:
91
+ group_ids = [d.id for d in group]
92
+ if any(gid in seen_diags for gid in group_ids):
93
+ continue
94
+
95
+ primary = group[0]
96
+ primary_suggestion = (
97
+ primary.suggestions[0]
98
+ if primary.suggestions
99
+ else Suggestion(description=f"Resolve dependencies for {pkg}")
100
+ )
101
+
102
+ root_causes.append(
103
+ RootCause(
104
+ id=f"ROOT-{rc_counter:03d}",
105
+ title=f"Package integrity issue with '{pkg}'",
106
+ summary=f"Found {len(group)} related findings affecting package '{pkg}'.",
107
+ primary_diagnostic=primary,
108
+ related_diagnostics=group[1:],
109
+ recommendation=primary_suggestion,
110
+ )
111
+ )
112
+ rc_counter += 1
113
+ for d in group:
114
+ seen_diags.add(d.id)
115
+
116
+ return root_causes