Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 94 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,114 @@
"name": "coverage-visualizer",
"displayName": "Python Coverage Visualizer",
"description": "Visualize Python test coverage inline in VS Code — highlights, CodeLens, dashboard, and sidebar tree view",
"version": "0.0.5",
"version": "1.0.0",
"publisher": "kool7",
"engines": {
"vscode": "^1.90.0"
},
"license": "MIT",
"icon": "assets/icon-256.png",
"categories": ["Testing", "Visualization", "Other"],
"keywords": ["python", "coverage", "test coverage", "pytest", "pytest-cov", "visualization"],
"repository": { "type": "git", "url": "https://github.com/kool7/coverage-visualizer" },
"activationEvents": ["onStartupFinished", "onView:coverageVisualizer.filesView"],
"categories": [
"Testing",
"Visualization",
"Other"
],
"keywords": [
"python",
"coverage",
"test coverage",
"pytest",
"pytest-cov",
"visualization"
],
"repository": {
"type": "git",
"url": "https://github.com/kool7/coverage-visualizer"
},
"activationEvents": [
"onStartupFinished",
"onView:coverageVisualizer.filesView"
],
"main": "./out/extension.js",
"contributes": {
"commands": [
{ "command": "coverage-visualizer.show", "title": "Coverage Visualizer: Show Coverage", "icon": "$(shield)" },
{ "command": "coverage-visualizer.clear", "title": "Coverage Visualizer: Clear Coverage", "icon": "$(close)" },
{ "command": "coverage-visualizer.showDashboard", "title": "Coverage Visualizer: Show Dashboard", "icon": "$(graph)" }
{
"command": "coverage-visualizer.show",
"title": "Coverage Visualizer: Show Coverage",
"icon": "$(shield)"
},
{
"command": "coverage-visualizer.clear",
"title": "Coverage Visualizer: Clear Coverage",
"icon": "$(close)"
},
{
"command": "coverage-visualizer.showDashboard",
"title": "Coverage Visualizer: Show Dashboard",
"icon": "$(graph)"
}
],
"views": {
"explorer": [
{ "id": "coverageVisualizer.filesView", "name": "Coverage", "when": "true" }
{
"id": "coverageVisualizer.filesView",
"name": "Coverage",
"when": "true"
}
]
},
"configuration": {
"title": "Coverage Visualizer",
"properties": {
"coverageVisualizer.thresholdGood": { "type": "number", "default": 80, "minimum": 0, "maximum": 100, "description": "Coverage % at or above which a file is considered well-covered (shown in green)." },
"coverageVisualizer.thresholdWarn": { "type": "number", "default": 50, "minimum": 0, "maximum": 100, "description": "Coverage % at or above which a file is a warning (yellow). Below this is red." },
"coverageVisualizer.coveredHighlightColor": { "type": "string", "default": "rgba(0, 180, 0, 0.10)", "description": "Background highlight color for covered lines. Any CSS color string." },
"coverageVisualizer.uncoveredHighlightColor": { "type": "string", "default": "rgba(220, 50, 50, 0.10)", "description": "Background highlight color for uncovered lines. Any CSS color string." },
"coverageVisualizer.enableCodeLens": { "type": "boolean", "default": true, "description": "Show coverage % above each function and class definition." },
"coverageVisualizer.enableHoverMessages": { "type": "boolean", "default": true, "description": "Show covered / not-covered tooltip when hovering a highlighted line." },
"coverageVisualizer.autoReloadOnChange": { "type": "boolean", "default": true, "description": "Automatically reload coverage when coverage.json / coverage.xml / .coverage changes on disk." },
"coverageVisualizer.coverageJsonPath": { "type": "string", "default": "coverage.json", "description": "Path to coverage.json relative to workspace root. Generate with: pytest --cov=. --cov-report=json" },
"coverageVisualizer.excludeTestFiles": { "type": "boolean", "default": true, "description": "Skip decorations and CodeLens on test files (test_*.py, *_test.py, files inside tests/ directories)." }
"coverageVisualizer.thresholdGood": {
"type": "number",
"default": 80,
"minimum": 0,
"maximum": 100,
"description": "Coverage % at or above which a file is considered well-covered (shown in green)."
},
"coverageVisualizer.thresholdWarn": {
"type": "number",
"default": 50,
"minimum": 0,
"maximum": 100,
"description": "Coverage % at or above which a file is a warning (yellow). Below this is red."
},
"coverageVisualizer.coveredHighlightColor": {
"type": "string",
"default": "rgba(0, 180, 0, 0.10)",
"description": "Background highlight color for covered lines. Any CSS color string."
},
"coverageVisualizer.uncoveredHighlightColor": {
"type": "string",
"default": "rgba(220, 50, 50, 0.10)",
"description": "Background highlight color for uncovered lines. Any CSS color string."
},
"coverageVisualizer.enableCodeLens": {
"type": "boolean",
"default": true,
"description": "Show coverage % above each function and class definition."
},
"coverageVisualizer.enableHoverMessages": {
"type": "boolean",
"default": true,
"description": "Show covered / not-covered tooltip when hovering a highlighted line."
},
"coverageVisualizer.autoReloadOnChange": {
"type": "boolean",
"default": true,
"description": "Automatically reload coverage when coverage.json / coverage.xml / .coverage changes on disk."
},
"coverageVisualizer.coverageJsonPath": {
"type": "string",
"default": "coverage.json",
"description": "Path to coverage.json relative to workspace root. Generate with: pytest --cov=. --cov-report=json"
},
"coverageVisualizer.excludeTestFiles": {
"type": "boolean",
"default": true,
"description": "Skip decorations and CodeLens on test files (test_*.py, *_test.py, files inside tests/ directories)."
}
}
}
},
Expand Down Expand Up @@ -65,5 +138,7 @@
"ts-node": "^10.9.2",
"typescript": "^5.4.0"
},
"dependencies": { "sql.js": "^1.14.1" }
"dependencies": {
"sql.js": "^1.14.1"
}
}
103 changes: 90 additions & 13 deletions src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { exec, spawn } from 'child_process';
import {
parseCoverageJson,
parseCoverageXml,
Expand All @@ -20,17 +21,15 @@ import { CoverageTreeProvider } from './providers/treeProvider.js';
let coveredDecoration: vscode.TextEditorDecorationType;
let uncoveredDecoration: vscode.TextEditorDecorationType;
let currentReport: CoverageReport | undefined;
let coverageRunInProgress = false;
let noCoveragePromptActive = false;
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
let coverageOutputChannel: vscode.OutputChannel | undefined;

const codeLensProvider = new CoverageCodeLensProvider();
const hoverProvider = new CoverageHoverProvider();
const treeProvider = new CoverageTreeProvider();

function isTestFile(fsPath: string): boolean {
const basename = path.basename(fsPath);
return basename.startsWith('test_') || basename.endsWith('_test.py') ||
fsPath.split(/[\\/]/).some(seg => seg === 'tests' || seg === 'test');
}

export function activate(context: vscode.ExtensionContext) {
createDecorations();
initStatusBar(context);
Expand Down Expand Up @@ -91,14 +90,19 @@ function setupWatchers(context: vscode.ExtensionContext) {
const root = vscode.workspace.workspaceFolders?.[0];
if (!root) return;

const debouncedReload = () => {
if (!getConfig().autoReloadOnChange) return;
clearTimeout(reloadTimer);
reloadTimer = setTimeout(() => loadAndApply(), 500);
};

const patterns = ['coverage.json', 'coverage.xml', '.coverage'];
patterns.forEach(pattern => {
const w = vscode.workspace.createFileSystemWatcher(
new vscode.RelativePattern(root, pattern)
);
const reload = () => { if (getConfig().autoReloadOnChange) loadAndApply(); };
w.onDidCreate(reload);
w.onDidChange(reload);
w.onDidCreate(debouncedReload);
w.onDidChange(debouncedReload);
w.onDidDelete(() => {
if (!findAnyCoverageFile(root.uri.fsPath)) clearCoverage();
});
Expand All @@ -112,11 +116,12 @@ async function loadAndApply() {

const result = await detectAndParse(workspaceFolder);
if (!result) {
vscode.window.showWarningMessage(
'Coverage Visualizer: No coverage file found. Run: pytest --cov=. --cov-report=json'
);
if (!coverageRunInProgress && !findAnyCoverageFile(workspaceFolder)) {
await handleNoCoverage(workspaceFolder);
}
return;
}
coverageRunInProgress = false;

const { report } = result;
currentReport = report;
Expand All @@ -129,7 +134,6 @@ async function loadAndApply() {

const { excludeTestFiles } = getConfig();
const filteredFiles = Object.entries(report.files)
.filter(([, d]) => d.executedLines.length + d.missingLines.length > 0)
.filter(([p]) => !excludeTestFiles || !isTestFile(p))
.map(([, d]) => d);
const filteredCovered = filteredFiles.reduce((n, f) => n + f.executedLines.length, 0);
Expand All @@ -143,6 +147,68 @@ async function loadAndApply() {
updateDashboard(report);
}

function resolvePython(workspaceFolder: string): string {
const candidates = [
path.join(workspaceFolder, '.venv', 'bin', 'python'),
path.join(workspaceFolder, '.venv', 'Scripts', 'python.exe'),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return 'python';
}

function checkPython(python: string, cwd: string, code: string): Promise<boolean> {
return new Promise(resolve => {
exec(`"${python}" -c "${code}"`, { cwd }, err => resolve(!err));
});
}

async function handleNoCoverage(workspaceFolder: string) {
if (noCoveragePromptActive) return;
noCoveragePromptActive = true;

try {
const python = resolvePython(workspaceFolder);
const [hasPytestCov, hasCoverage] = await Promise.all([
checkPython(python, workspaceFolder, 'import pytest_cov'),
checkPython(python, workspaceFolder, 'import coverage'),
]);

if (!hasCoverage) {
vscode.window.showWarningMessage('coverage is not installed — add it as a dev dependency to enable auto-run.');
return;
}

const choice = await vscode.window.showInformationMessage(
'No coverage found.',
'Run pytest',
'Cancel'
);
if (choice !== 'Run pytest') return;

const args = hasPytestCov
? ['-m', 'pytest', '--cov=.', '--cov-report=json']
: ['-m', 'coverage', 'run', '-m', 'pytest'];

coverageRunInProgress = true;
coverageOutputChannel ??= vscode.window.createOutputChannel('Coverage Run');
coverageOutputChannel.clear();
coverageOutputChannel.show(true);
coverageOutputChannel.appendLine(`$ ${python} ${args.join(' ')}\n`);

const proc = spawn(python, args, { cwd: workspaceFolder });
proc.stdout.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString()));
proc.stderr.on('data', (d: Buffer) => coverageOutputChannel!.append(d.toString()));
proc.on('close', code => {
coverageRunInProgress = false;
coverageOutputChannel!.appendLine(`\n[exited ${code ?? '?'}]`);
});
} finally {
noCoveragePromptActive = false;
}
}

async function detectAndParse(
workspaceFolder: string
): Promise<{ report: CoverageReport; formatUsed: string } | undefined> {
Expand Down Expand Up @@ -194,8 +260,18 @@ function clearCoverage() {
});
}

function isTestFile(fsPath: string): boolean {
const basename = path.basename(fsPath);
return basename.startsWith('test_') || basename.endsWith('_test.py') ||
fsPath.split(path.sep).some(seg => seg === 'tests' || seg === 'test');
}

function applyToEditor(editor: vscode.TextEditor, report: CoverageReport) {
if (getConfig().excludeTestFiles && isTestFile(editor.document.uri.fsPath)) {
editor.setDecorations(coveredDecoration, []);
editor.setDecorations(uncoveredDecoration, []);
return;
}
const fileCoverage = findFileInReport(report, editor.document.uri.fsPath);
if (!fileCoverage) {
editor.setDecorations(coveredDecoration, []);
Expand All @@ -215,5 +291,6 @@ function linesToDecorations(lines: number[]): vscode.DecorationOptions[] {
export function deactivate() {
coveredDecoration?.dispose();
uncoveredDecoration?.dispose();
coverageOutputChannel?.dispose();
currentReport = undefined;
}
Loading
Loading