Skip to content

Commit 396b98f

Browse files
committed
Detect installed packages in the selected environment
1 parent b0da28c commit 396b98f

File tree

10 files changed

+245
-12
lines changed

10 files changed

+245
-12
lines changed

package.json

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1526,10 +1526,8 @@
15261526
],
15271527
"configuration": "./languages/pip-requirements.json",
15281528
"filenamePatterns": [
1529-
"**/*-requirements.{txt, in}",
1530-
"**/*-constraints.txt",
1531-
"**/requirements-*.{txt, in}",
1532-
"**/constraints-*.txt",
1529+
"**/*requirements*.{txt, in}",
1530+
"**/*constraints*.txt",
15331531
"**/requirements/*.{txt,in}",
15341532
"**/constraints/*.txt"
15351533
],
@@ -1733,7 +1731,7 @@
17331731
{
17341732
"group": "Python",
17351733
"command": "python.createEnvironment-button",
1736-
"when": "showCreateEnvButton && resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported && !inDiffEditor"
1734+
"when": "showCreateEnvButton && resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported && !inDiffEditor && pipDepsNotInstalled"
17371735
},
17381736
{
17391737
"group": "Python",

pythonFiles/installed_check.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import argparse
5+
import json
6+
import os
7+
import pathlib
8+
import sys
9+
from typing import Optional, Sequence
10+
11+
LIB_ROOT = pathlib.Path(__file__).parent / "lib" / "python"
12+
sys.path.insert(0, os.fspath(LIB_ROOT))
13+
14+
from importlib_metadata import metadata
15+
from packaging.requirements import Requirement
16+
17+
18+
def parse_args(argv: Optional[Sequence[str]] = None):
19+
if argv is None:
20+
argv = sys.argv[1:]
21+
parser = argparse.ArgumentParser(
22+
description="Check for installed packages against requirements"
23+
)
24+
parser.add_argument(
25+
"REQUIREMENTS", type=str, help="Path to requirements.[txt, in]", nargs="+"
26+
)
27+
28+
return parser.parse_args(argv)
29+
30+
31+
def parse_requirements(line: str) -> Optional[Requirement]:
32+
try:
33+
req = Requirement(line.strip("\\"))
34+
if req.marker is None:
35+
return req
36+
elif req.marker.evaluate():
37+
return req
38+
except:
39+
return None
40+
41+
42+
def main():
43+
args = parse_args()
44+
45+
diagnostics = []
46+
for req_file in args.REQUIREMENTS:
47+
req_file = pathlib.Path(req_file)
48+
if req_file.exists():
49+
lines = req_file.read_text(encoding="utf-8").splitlines()
50+
for n, line in enumerate(lines):
51+
if line.startswith(("#", "-", " ")) or line == "":
52+
continue
53+
54+
req = parse_requirements(line)
55+
if req:
56+
try:
57+
# Check if package is installed
58+
metadata(req.name)
59+
except:
60+
diagnostics.append(
61+
{
62+
"line": n,
63+
"package": req.name,
64+
"code": "not-installed",
65+
"severity": 3,
66+
}
67+
)
68+
print(json.dumps(diagnostics, ensure_ascii=False))
69+
70+
71+
if __name__ == "__main__":
72+
main()

requirements.in

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ typing-extensions==4.5.0
88

99
# Fallback env creator for debian
1010
microvenv
11+
12+
# Checker for installed packages
13+
packaging
14+
importlib_metadata

requirements.txt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,25 @@
44
#
55
# pip-compile --generate-hashes requirements.in
66
#
7+
importlib-metadata==6.6.0 \
8+
--hash=sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed \
9+
--hash=sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705
10+
# via -r requirements.in
711
microvenv==2023.2.0 \
812
--hash=sha256:5b46296d6a65992946da504bd9e724a5becf5c256091f2f9383e5b4e9f567f23 \
913
--hash=sha256:a07e88a8fb5ee90219b86dd90095cb5646462d45d30285ea3b1a3c7cf33616d3
1014
# via -r requirements.in
15+
packaging==23.1 \
16+
--hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \
17+
--hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f
18+
# via -r requirements.in
1119
typing-extensions==4.5.0 \
1220
--hash=sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb \
1321
--hash=sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4
14-
# via -r requirements.in
22+
# via
23+
# -r requirements.in
24+
# importlib-metadata
25+
zipp==3.15.0 \
26+
--hash=sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b \
27+
--hash=sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556
28+
# via importlib-metadata

src/client/common/process/internal/scripts/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,8 @@ export function createCondaScript(): string {
149149
const script = path.join(SCRIPTS_DIR, 'create_conda.py');
150150
return script;
151151
}
152+
153+
export function installedCheckScript(): string {
154+
const script = path.join(SCRIPTS_DIR, 'installed_check.py');
155+
return script;
156+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License
3+
4+
import { DiagnosticChangeEvent, DiagnosticCollection, Disposable, languages } from 'vscode';
5+
6+
export function createDiagnosticCollection(name: string): DiagnosticCollection {
7+
return languages.createDiagnosticCollection(name);
8+
}
9+
10+
export function onDidChangeDiagnostics(handler: (e: DiagnosticChangeEvent) => void): Disposable {
11+
return languages.onDidChangeDiagnostics(handler);
12+
}

src/client/common/vscodeApis/windowApis.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export function getActiveTextEditor(): TextEditor | undefined {
7777
return activeTextEditor;
7878
}
7979

80+
export function onDidChangeActiveTextEditor(handler: (e: TextEditor | undefined) => void): Disposable {
81+
return window.onDidChangeActiveTextEditor(handler);
82+
}
83+
8084
export enum MultiStepAction {
8185
Back = 'Back',
8286
Cancel = 'Cancel',

src/client/common/vscodeApis/workspaceApis.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ export function findFiles(
3636
return vscode.workspace.findFiles(include, exclude, maxResults, token);
3737
}
3838

39-
export function onDidSaveTextDocument(
40-
listener: (e: vscode.TextDocument) => unknown,
41-
thisArgs?: unknown,
42-
disposables?: vscode.Disposable[],
43-
): vscode.Disposable {
44-
return vscode.workspace.onDidSaveTextDocument(listener, thisArgs, disposables);
39+
export function onDidCloseTextDocument(handler: (e: vscode.TextDocument) => unknown): vscode.Disposable {
40+
return vscode.workspace.onDidCloseTextDocument(handler);
41+
}
42+
43+
export function onDidSaveTextDocument(handler: (e: vscode.TextDocument) => unknown): vscode.Disposable {
44+
return vscode.workspace.onDidSaveTextDocument(handler);
4545
}
4646

4747
export function getOpenTextDocuments(): readonly vscode.TextDocument[] {

src/client/extensionActivation.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { registerCreateEnvironmentFeatures } from './pythonEnvironments/creation
5555
import { IInterpreterQuickPick } from './interpreter/configuration/types';
5656
import { registerInstallFormatterPrompt } from './providers/prompts/installFormatterPrompt';
5757
import { registerCreateEnvButtonFeatures } from './pythonEnvironments/creation/createEnvButtonContext';
58+
import { registerInstalledPackagesChecking } from './pythonEnvironments/creation/installedPackagesDiagnostic';
5859

5960
export async function activateComponents(
6061
// `ext` is passed to any extra activation funcs.
@@ -99,6 +100,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components):
99100
const pathUtils = ext.legacyIOC.serviceContainer.get<IPathUtils>(IPathUtils);
100101
registerCreateEnvironmentFeatures(ext.disposables, interpreterQuickPick, interpreterPathService, pathUtils);
101102
registerCreateEnvButtonFeatures(ext.disposables);
103+
registerInstalledPackagesChecking(interpreterPathService, ext.disposables);
102104
}
103105

104106
/// //////////////////////////
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License
3+
4+
import { Diagnostic, DiagnosticCollection, DiagnosticSeverity, l10n, Range, TextDocument, Uri } from 'vscode';
5+
import { installedCheckScript } from '../../common/process/internal/scripts';
6+
import { plainExec } from '../../common/process/rawProcessApis';
7+
import { IDisposableRegistry, IInterpreterPathService } from '../../common/types';
8+
import { executeCommand } from '../../common/vscodeApis/commandApis';
9+
import { createDiagnosticCollection, onDidChangeDiagnostics } from '../../common/vscodeApis/languageApis';
10+
import { getActiveTextEditor, onDidChangeActiveTextEditor } from '../../common/vscodeApis/windowApis';
11+
import {
12+
getOpenTextDocuments,
13+
onDidCloseTextDocument,
14+
onDidOpenTextDocument,
15+
onDidSaveTextDocument,
16+
} from '../../common/vscodeApis/workspaceApis';
17+
import { traceVerbose } from '../../logging';
18+
19+
interface PackageDiagnostic {
20+
package: string;
21+
line: number;
22+
code: string;
23+
severity: DiagnosticSeverity;
24+
}
25+
26+
const SOURCE = 'Python-Ext';
27+
const PIP_DEPS_NOT_INSTALLED_KEY = 'pipDepsNotInstalled';
28+
29+
async function getPipRequirementsDiagnostics(
30+
interpreterPathService: IInterpreterPathService,
31+
doc: TextDocument,
32+
): Promise<Diagnostic[]> {
33+
const interpreter = interpreterPathService.get(doc.uri);
34+
const result = await plainExec(interpreter, [installedCheckScript(), doc.uri.fsPath]);
35+
traceVerbose('Installed packages check result:\n', result.stdout);
36+
let diagnostics: Diagnostic[] = [];
37+
try {
38+
const raw = JSON.parse(result.stdout) as PackageDiagnostic[];
39+
diagnostics = raw.map((item) => {
40+
const d = new Diagnostic(
41+
new Range(item.line, 0, item.line, item.package.length),
42+
l10n.t(`Package \`${item.package}\` is not installed in the selected environment.`),
43+
item.severity,
44+
);
45+
d.code = { value: item.code, target: Uri.parse(`https://pypi.org/p/${item.package}`) };
46+
d.source = SOURCE;
47+
return d;
48+
});
49+
} catch {
50+
diagnostics = [];
51+
}
52+
return diagnostics;
53+
}
54+
55+
async function setContextForActiveEditor(diagnosticCollection: DiagnosticCollection): Promise<void> {
56+
const doc = getActiveTextEditor()?.document;
57+
if (doc && doc.languageId === 'pip-requirements') {
58+
const diagnostics = diagnosticCollection.get(doc.uri);
59+
if (diagnostics && diagnostics.length > 0) {
60+
traceVerbose(`Setting context for pip dependencies not installed: ${doc.uri.fsPath}`);
61+
await executeCommand('setContext', PIP_DEPS_NOT_INSTALLED_KEY, true);
62+
return;
63+
}
64+
}
65+
66+
// undefined here in the logs means no file was selected
67+
traceVerbose(`Clearing context for pip dependencies not installed: ${doc?.uri.fsPath}`);
68+
await executeCommand('setContext', PIP_DEPS_NOT_INSTALLED_KEY, false);
69+
}
70+
71+
export function registerInstalledPackagesChecking(
72+
interpreterPathService: IInterpreterPathService,
73+
disposables: IDisposableRegistry,
74+
): void {
75+
const diagnosticCollection = createDiagnosticCollection(SOURCE);
76+
77+
disposables.push(diagnosticCollection);
78+
disposables.push(
79+
onDidOpenTextDocument(async (e: TextDocument) => {
80+
if (e.languageId === 'pip-requirements') {
81+
const diagnostics = await getPipRequirementsDiagnostics(interpreterPathService, e);
82+
if (diagnostics.length > 0) {
83+
diagnosticCollection.set(e.uri, diagnostics);
84+
} else if (diagnosticCollection.has(e.uri)) {
85+
diagnosticCollection.delete(e.uri);
86+
}
87+
}
88+
}),
89+
onDidSaveTextDocument(async (e: TextDocument) => {
90+
if (e.languageId === 'pip-requirements') {
91+
const diagnostics = await getPipRequirementsDiagnostics(interpreterPathService, e);
92+
if (diagnostics.length > 0) {
93+
diagnosticCollection.set(e.uri, diagnostics);
94+
} else if (diagnosticCollection.has(e.uri)) {
95+
diagnosticCollection.delete(e.uri);
96+
}
97+
}
98+
}),
99+
onDidCloseTextDocument((e: TextDocument) => {
100+
if (diagnosticCollection.has(e.uri)) {
101+
diagnosticCollection.delete(e.uri);
102+
}
103+
}),
104+
onDidChangeDiagnostics(async () => {
105+
await setContextForActiveEditor(diagnosticCollection);
106+
}),
107+
onDidChangeActiveTextEditor(async () => {
108+
await setContextForActiveEditor(diagnosticCollection);
109+
}),
110+
);
111+
112+
getOpenTextDocuments().forEach(async (doc: TextDocument) => {
113+
if (doc.languageId === 'pip-requirements') {
114+
const diagnostics = await getPipRequirementsDiagnostics(interpreterPathService, doc);
115+
if (diagnostics.length > 0) {
116+
diagnosticCollection.set(doc.uri, diagnostics);
117+
} else if (diagnosticCollection.has(doc.uri)) {
118+
diagnosticCollection.delete(doc.uri);
119+
}
120+
}
121+
});
122+
}

0 commit comments

Comments
 (0)