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.
@@ -0,0 +1 @@
1
+ """Docker analyzers."""
@@ -0,0 +1,5 @@
1
+ """Environment analyzers."""
2
+
3
+ from qv.analyzers.environment.drift import EnvironmentAnalyzer
4
+
5
+ __all__ = ["EnvironmentAnalyzer"]
@@ -0,0 +1,164 @@
1
+ """Environment analyzer implementing ENV-001, ENV-002, ENV-003."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from packaging.specifiers import SpecifierSet
6
+ from packaging.version import Version
7
+
8
+ from qv.core.context import ProjectContext
9
+ from qv.core.models import Diagnostic, Evidence, Suggestion
10
+ from qv.rules.registry import get_rule_definition
11
+
12
+
13
+ class EnvironmentAnalyzer:
14
+ """Analyzes runtime environments, Dockerfiles, and CI matrices for drift."""
15
+
16
+ id = "environment"
17
+ name = "Environment Analyzer"
18
+ description = (
19
+ "Checks for Python runtime drift, Docker runtime drift, and CI test matrix mismatches."
20
+ )
21
+
22
+ def analyze(self, context: ProjectContext) -> list[Diagnostic]:
23
+ diagnostics: list[Diagnostic] = []
24
+
25
+ diagnostics.extend(self._check_python_version_drift(context))
26
+ diagnostics.extend(self._check_docker_drift(context))
27
+ diagnostics.extend(self._check_ci_drift(context))
28
+
29
+ return diagnostics
30
+
31
+ def _check_python_version_drift(self, context: ProjectContext) -> list[Diagnostic]:
32
+ """ENV-001: Check if running Python satisfies project requires-python or config."""
33
+ diagnostics: list[Diagnostic] = []
34
+ rule = get_rule_definition("ENV-001")
35
+ if not rule:
36
+ return diagnostics
37
+
38
+ target_python = context.config.get("target_python")
39
+ if target_python:
40
+ try:
41
+ spec = SpecifierSet(
42
+ target_python
43
+ if any(c in target_python for c in "<>=~!")
44
+ else f"=={target_python}.*"
45
+ )
46
+ current_ver = Version(context.python_runtime.version_str)
47
+ if not spec.contains(current_ver):
48
+ diagnostics.append(
49
+ Diagnostic(
50
+ id=rule.id,
51
+ severity=rule.default_severity,
52
+ category=rule.category,
53
+ title=rule.title,
54
+ message=f"Active Python {context.python_runtime.version_str} does not match target Python {target_python}.",
55
+ evidence=[
56
+ Evidence(
57
+ fact=f"Active Python version: {context.python_runtime.version_str}",
58
+ source=context.python_runtime.executable
59
+ or "Active Interpreter",
60
+ ),
61
+ Evidence(
62
+ fact=f"Configured Python target: {target_python}",
63
+ source="pyproject.toml",
64
+ ),
65
+ ],
66
+ suggestions=[
67
+ Suggestion(
68
+ description=f"Use Python {target_python} for your virtual environment.",
69
+ command=f"uv venv --python {target_python}"
70
+ if context.package_manager == "uv"
71
+ else None,
72
+ )
73
+ ],
74
+ doc_url=rule.doc_url,
75
+ )
76
+ )
77
+ except Exception:
78
+ pass
79
+
80
+ return diagnostics
81
+
82
+ def _check_docker_drift(self, context: ProjectContext) -> list[Diagnostic]:
83
+ """ENV-002: Check Dockerfile Python version against target/active Python."""
84
+ diagnostics: list[Diagnostic] = []
85
+ rule = get_rule_definition("ENV-002")
86
+ if not rule or not context.docker.has_dockerfile or not context.docker.base_python_version:
87
+ return diagnostics
88
+
89
+ docker_py = context.docker.base_python_version
90
+ active_py = f"{context.python_runtime.major}.{context.python_runtime.minor}"
91
+
92
+ if docker_py != active_py:
93
+ rel_docker = (
94
+ context.docker.dockerfile_path.relative_to(context.project_root)
95
+ if context.docker.dockerfile_path
96
+ and context.docker.dockerfile_path.is_relative_to(context.project_root)
97
+ else "Dockerfile"
98
+ )
99
+ diagnostics.append(
100
+ Diagnostic(
101
+ id=rule.id,
102
+ severity=rule.default_severity,
103
+ category=rule.category,
104
+ title=rule.title,
105
+ message=f"Dockerfile base image uses Python {docker_py}, while local runtime is {active_py}.",
106
+ evidence=[
107
+ Evidence(
108
+ fact=f"Dockerfile base image: {context.docker.base_image}",
109
+ source=str(rel_docker),
110
+ ),
111
+ Evidence(
112
+ fact=f"Local Python version: {context.python_runtime.version_str}",
113
+ source="Local environment",
114
+ ),
115
+ ],
116
+ suggestions=[
117
+ Suggestion(
118
+ description=f"Update base image in {rel_docker} to use python:{active_py}-slim.",
119
+ code_snippet=f"FROM python:{active_py}-slim",
120
+ )
121
+ ],
122
+ file=str(rel_docker),
123
+ doc_url=rule.doc_url,
124
+ )
125
+ )
126
+
127
+ return diagnostics
128
+
129
+ def _check_ci_drift(self, context: ProjectContext) -> list[Diagnostic]:
130
+ """ENV-003: Check CI workflow python versions against active/target python."""
131
+ diagnostics: list[Diagnostic] = []
132
+ rule = get_rule_definition("ENV-003")
133
+ if not rule or not context.ci.has_ci or not context.ci.matrix_python_versions:
134
+ return diagnostics
135
+
136
+ active_major_minor = f"{context.python_runtime.major}.{context.python_runtime.minor}"
137
+ if active_major_minor not in context.ci.matrix_python_versions:
138
+ diagnostics.append(
139
+ Diagnostic(
140
+ id=rule.id,
141
+ severity=rule.default_severity,
142
+ category=rule.category,
143
+ title=rule.title,
144
+ message=f"Local Python {active_major_minor} is not tested in CI matrix ({', '.join(context.ci.matrix_python_versions)}).",
145
+ evidence=[
146
+ Evidence(
147
+ fact=f"CI test matrix versions: {', '.join(context.ci.matrix_python_versions)}",
148
+ source="CI workflows",
149
+ ),
150
+ Evidence(
151
+ fact=f"Local Python version: {active_major_minor}",
152
+ source="Local environment",
153
+ ),
154
+ ],
155
+ suggestions=[
156
+ Suggestion(
157
+ description=f"Add '{active_major_minor}' to your CI matrix configuration.",
158
+ )
159
+ ],
160
+ doc_url=rule.doc_url,
161
+ )
162
+ )
163
+
164
+ return diagnostics
@@ -0,0 +1,5 @@
1
+ """Import analyzers."""
2
+
3
+ from qv.analyzers.imports.analyzer import ImportAnalyzer
4
+
5
+ __all__ = ["ImportAnalyzer"]
@@ -0,0 +1,160 @@
1
+ """Import and Architecture analyzer implementing IMP-001 and IMP-002."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from qv.core.context import ProjectContext
8
+ from qv.core.models import Diagnostic, Evidence, Suggestion
9
+ from qv.rules.registry import get_rule_definition
10
+
11
+
12
+ class ImportAnalyzer:
13
+ """Analyzes import graphs for circular dependencies and unresolved local imports."""
14
+
15
+ id = "imports"
16
+ name = "Import & Architecture Analyzer"
17
+ description = "Detects circular imports and unresolved internal modules."
18
+
19
+ def analyze(self, context: ProjectContext) -> list[Diagnostic]:
20
+ diagnostics: list[Diagnostic] = []
21
+
22
+ diagnostics.extend(self._check_circular_imports(context))
23
+ diagnostics.extend(self._check_unresolved_local_imports(context))
24
+
25
+ return diagnostics
26
+
27
+ def _check_circular_imports(self, context: ProjectContext) -> list[Diagnostic]:
28
+ """IMP-001: Detect cycles in local import graph."""
29
+ diagnostics: list[Diagnostic] = []
30
+ rule = get_rule_definition("IMP-001")
31
+ if not rule or not context.source_files:
32
+ return diagnostics
33
+
34
+ # Build module mapping
35
+ module_to_file: dict[str, str] = {}
36
+ file_to_module: dict[str, str] = {}
37
+ for sf in context.source_files:
38
+ module_to_file[sf.module_name] = str(sf.relative_path)
39
+ file_to_module[str(sf.path)] = sf.module_name
40
+
41
+ # Build graph
42
+ graph: dict[str, set[str]] = defaultdict(set)
43
+ for imp in context.imports:
44
+ src_mod = file_to_module.get(str(imp.source_file))
45
+ if not src_mod:
46
+ continue
47
+
48
+ target_mod = imp.module_name
49
+ # If target module is in project
50
+ if target_mod in module_to_file and target_mod != src_mod:
51
+ graph[src_mod].add(target_mod)
52
+
53
+ # Detect cycles using DFS
54
+ visited: set[str] = set()
55
+ recursion_stack: list[str] = []
56
+ detected_cycles: list[list[str]] = []
57
+
58
+ def dfs(node: str) -> None:
59
+ visited.add(node)
60
+ recursion_stack.append(node)
61
+
62
+ for neighbor in graph.get(node, []):
63
+ if neighbor not in visited:
64
+ dfs(neighbor)
65
+ elif neighbor in recursion_stack:
66
+ # Cycle found
67
+ idx = recursion_stack.index(neighbor)
68
+ cycle = recursion_stack[idx:] + [neighbor]
69
+ detected_cycles.append(cycle)
70
+
71
+ recursion_stack.pop()
72
+
73
+ for mod in list(graph.keys()):
74
+ if mod not in visited:
75
+ dfs(mod)
76
+
77
+ # Format diagnostics for cycles
78
+ seen_cycle_sets = set()
79
+ for cycle in detected_cycles:
80
+ cycle_key = tuple(sorted(set(cycle)))
81
+ if cycle_key in seen_cycle_sets:
82
+ continue
83
+ seen_cycle_sets.add(cycle_key)
84
+
85
+ cycle_str = " -> ".join(cycle)
86
+ primary_mod = cycle[0]
87
+ rel_file = module_to_file.get(primary_mod, primary_mod)
88
+
89
+ diagnostics.append(
90
+ Diagnostic(
91
+ id=rule.id,
92
+ severity=rule.default_severity,
93
+ category=rule.category,
94
+ title="Circular import detected",
95
+ message=f"Circular import cycle detected: {cycle_str}",
96
+ evidence=[
97
+ Evidence(
98
+ fact=f"Import chain: {cycle_str}",
99
+ source=rel_file,
100
+ )
101
+ ],
102
+ suggestions=[
103
+ Suggestion(
104
+ description=f"Break the cycle between {cycle[0]} and {cycle[1]} by refactoring shared logic into a separate module or using deferred imports inside functions.",
105
+ )
106
+ ],
107
+ file=rel_file,
108
+ doc_url=rule.doc_url,
109
+ )
110
+ )
111
+
112
+ return diagnostics
113
+
114
+ def _check_unresolved_local_imports(self, context: ProjectContext) -> list[Diagnostic]:
115
+ """IMP-002: Detect local imports that cannot be resolved to any project source file."""
116
+ diagnostics: list[Diagnostic] = []
117
+ rule = get_rule_definition("IMP-002")
118
+ if not rule or not context.source_files:
119
+ return diagnostics
120
+
121
+ known_modules = {sf.module_name for sf in context.source_files}
122
+
123
+ for imp in context.imports:
124
+ if not imp.module_name:
125
+ continue
126
+
127
+ # If it's a relative import or clearly aiming at a known root
128
+ if imp.is_relative:
129
+ rel_path = (
130
+ imp.source_file.relative_to(context.project_root)
131
+ if imp.source_file.is_relative_to(context.project_root)
132
+ else imp.source_file
133
+ )
134
+ # If relative and target module is empty or not in known modules
135
+ if imp.module_name and imp.module_name not in known_modules:
136
+ diagnostics.append(
137
+ Diagnostic(
138
+ id=rule.id,
139
+ severity=rule.default_severity,
140
+ category=rule.category,
141
+ title=f"Unresolved relative import: {imp.module_name}",
142
+ message=f"Module '{imp.module_name}' imported at {rel_path}:{imp.line_number} could not be resolved.",
143
+ evidence=[
144
+ Evidence(
145
+ fact=f"Relative import statement at {rel_path}:{imp.line_number}",
146
+ source=str(rel_path),
147
+ )
148
+ ],
149
+ suggestions=[
150
+ Suggestion(
151
+ description=f"Verify target file exists or correct relative import path in {rel_path}.",
152
+ )
153
+ ],
154
+ file=str(rel_path),
155
+ line=imp.line_number,
156
+ doc_url=rule.doc_url,
157
+ )
158
+ )
159
+
160
+ return diagnostics
@@ -0,0 +1,5 @@
1
+ """Packaging analyzers."""
2
+
3
+ from qv.analyzers.packaging.analyzer import PackagingAnalyzer
4
+
5
+ __all__ = ["PackagingAnalyzer"]
@@ -0,0 +1,117 @@
1
+ """Packaging analyzer implementing PKG-001 and PKG-002."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from qv.core.context import ProjectContext
8
+ from qv.core.models import Diagnostic, Evidence, Suggestion
9
+ from qv.rules.registry import get_rule_definition
10
+
11
+ if sys.version_info >= (3, 11):
12
+ import tomllib
13
+ else:
14
+ import tomli as tomllib
15
+
16
+
17
+ class PackagingAnalyzer:
18
+ """Analyzes pyproject.toml and packaging metadata for validity and completeness."""
19
+
20
+ id = "packaging"
21
+ name = "Packaging Analyzer"
22
+ description = (
23
+ "Checks pyproject.toml and project metadata for PEP 621 compliance and completeness."
24
+ )
25
+
26
+ def analyze(self, context: ProjectContext) -> list[Diagnostic]:
27
+ diagnostics: list[Diagnostic] = []
28
+ pyproject_path = context.project_root / "pyproject.toml"
29
+
30
+ if not pyproject_path.exists():
31
+ return diagnostics
32
+
33
+ try:
34
+ with open(pyproject_path, "rb") as f:
35
+ data = tomllib.load(f)
36
+ except Exception as e:
37
+ rule_pkg002 = get_rule_definition("PKG-002")
38
+ if rule_pkg002:
39
+ diagnostics.append(
40
+ Diagnostic(
41
+ id=rule_pkg002.id,
42
+ severity=rule_pkg002.default_severity,
43
+ category=rule_pkg002.category,
44
+ title=rule_pkg002.title,
45
+ message=f"Failed to parse pyproject.toml: {e}",
46
+ evidence=[
47
+ Evidence(
48
+ fact=str(e),
49
+ source="pyproject.toml",
50
+ )
51
+ ],
52
+ suggestions=[
53
+ Suggestion(
54
+ description="Check TOML syntax in pyproject.toml for missing quotes, brackets, or invalid keys.",
55
+ )
56
+ ],
57
+ file="pyproject.toml",
58
+ doc_url=rule_pkg002.doc_url,
59
+ )
60
+ )
61
+ return diagnostics
62
+
63
+ # Check PKG-001: Missing metadata
64
+ rule_pkg001 = get_rule_definition("PKG-001")
65
+ if rule_pkg001:
66
+ project_table = data.get("project")
67
+ poetry_table = data.get("tool", {}).get("poetry")
68
+
69
+ if project_table is None and poetry_table is None:
70
+ diagnostics.append(
71
+ Diagnostic(
72
+ id=rule_pkg001.id,
73
+ severity=rule_pkg001.default_severity,
74
+ category=rule_pkg001.category,
75
+ title="Missing [project] metadata in pyproject.toml",
76
+ message="pyproject.toml does not contain a standard PEP 621 [project] table or tool metadata table.",
77
+ evidence=[
78
+ Evidence(
79
+ fact="Neither [project] nor [tool.poetry] table found in pyproject.toml",
80
+ source="pyproject.toml",
81
+ )
82
+ ],
83
+ suggestions=[
84
+ Suggestion(
85
+ description="Add a [project] table with name, version, and dependencies.",
86
+ )
87
+ ],
88
+ file="pyproject.toml",
89
+ doc_url=rule_pkg001.doc_url,
90
+ )
91
+ )
92
+ elif project_table is not None:
93
+ if not project_table.get("name"):
94
+ diagnostics.append(
95
+ Diagnostic(
96
+ id=rule_pkg001.id,
97
+ severity=rule_pkg001.default_severity,
98
+ category=rule_pkg001.category,
99
+ title="Missing project name in [project] table",
100
+ message="[project] table in pyproject.toml is missing the required 'name' field.",
101
+ evidence=[
102
+ Evidence(
103
+ fact="'name' field is missing or empty in [project]",
104
+ source="pyproject.toml",
105
+ )
106
+ ],
107
+ suggestions=[
108
+ Suggestion(
109
+ description='Add name = "my-project" to [project].',
110
+ )
111
+ ],
112
+ file="pyproject.toml",
113
+ doc_url=rule_pkg001.doc_url,
114
+ )
115
+ )
116
+
117
+ return diagnostics
@@ -0,0 +1 @@
1
+ """Security analyzers."""
qv/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """CLI package for qv."""
2
+
3
+ from qv.cli.main import cli, main
4
+
5
+ __all__ = ["cli", "main"]