From a74f1d15b58e2a835f5564449918452ff30d62d6 Mon Sep 17 00:00:00 2001 From: Karthik Nadig Date: Tue, 16 May 2023 11:18:18 -0700 Subject: [PATCH] Detect installed packages in the selected environment (#21231) Fixes https://github.com/microsoft/vscode-python/issues/21140 --- package.json | 10 +- pythonFiles/installed_check.py | 129 +++++++ pythonFiles/tests/test_data/missing-deps.data | 121 +++++++ .../tests/test_data/no-missing-deps.data | 13 + .../test_data/pyproject-missing-deps.data | 9 + .../test_data/pyproject-no-missing-deps.data | 9 + pythonFiles/tests/test_installed_check.py | 90 +++++ requirements.in | 5 + requirements.txt | 20 +- .../common/process/internal/scripts/index.ts | 5 + src/client/common/vscodeApis/languageApis.ts | 12 + src/client/common/vscodeApis/windowApis.ts | 4 + src/client/common/vscodeApis/workspaceApis.ts | 12 +- src/client/extensionActivation.ts | 6 +- .../creation/common/installCheckUtils.ts | 60 ++++ .../creation/createEnvButtonContext.ts | 40 +-- .../creation/installedPackagesDiagnostic.ts | 88 +++++ .../creation/pyProjectTomlContext.ts | 44 +++ .../creation/registrations.ts | 21 ++ .../common/installCheckUtils.unit.test.ts | 65 ++++ .../createEnvButtonContext.unit.test.ts | 255 +------------- .../installedPackagesDiagnostics.unit.test.ts | 333 ++++++++++++++++++ .../pyProjectTomlContext.unit.test.ts | 266 ++++++++++++++ 23 files changed, 1314 insertions(+), 303 deletions(-) create mode 100644 pythonFiles/installed_check.py create mode 100644 pythonFiles/tests/test_data/missing-deps.data create mode 100644 pythonFiles/tests/test_data/no-missing-deps.data create mode 100644 pythonFiles/tests/test_data/pyproject-missing-deps.data create mode 100644 pythonFiles/tests/test_data/pyproject-no-missing-deps.data create mode 100644 pythonFiles/tests/test_installed_check.py create mode 100644 src/client/common/vscodeApis/languageApis.ts create mode 100644 src/client/pythonEnvironments/creation/common/installCheckUtils.ts create mode 100644 src/client/pythonEnvironments/creation/installedPackagesDiagnostic.ts create mode 100644 src/client/pythonEnvironments/creation/pyProjectTomlContext.ts create mode 100644 src/client/pythonEnvironments/creation/registrations.ts create mode 100644 src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts create mode 100644 src/test/pythonEnvironments/creation/installedPackagesDiagnostics.unit.test.ts create mode 100644 src/test/pythonEnvironments/creation/pyProjectTomlContext.unit.test.ts diff --git a/package.json b/package.json index 2109483b817f..8b364b5d60e6 100644 --- a/package.json +++ b/package.json @@ -1526,10 +1526,8 @@ ], "configuration": "./languages/pip-requirements.json", "filenamePatterns": [ - "**/*-requirements.{txt, in}", - "**/*-constraints.txt", - "**/requirements-*.{txt, in}", - "**/constraints-*.txt", + "**/*requirements*.{txt, in}", + "**/*constraints*.txt", "**/requirements/*.{txt,in}", "**/constraints/*.txt" ], @@ -1733,12 +1731,12 @@ { "group": "Python", "command": "python.createEnvironment-button", - "when": "showCreateEnvButton && resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported && !inDiffEditor" + "when": "showCreateEnvButton && resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported && !inDiffEditor && pythonDepsNotInstalled" }, { "group": "Python", "command": "python.createEnvironment-button", - "when": "showCreateEnvButton && resourceFilename == pyproject.toml && pipInstallableToml && !virtualWorkspace && shellExecutionSupported && !inDiffEditor" + "when": "showCreateEnvButton && resourceFilename == pyproject.toml && pipInstallableToml && !virtualWorkspace && shellExecutionSupported && !inDiffEditor && pythonDepsNotInstalled" } ], "editor/context": [ diff --git a/pythonFiles/installed_check.py b/pythonFiles/installed_check.py new file mode 100644 index 000000000000..f0e1c268d270 --- /dev/null +++ b/pythonFiles/installed_check.py @@ -0,0 +1,129 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import argparse +import json +import os +import pathlib +import sys +from typing import Dict, List, Optional, Sequence, Tuple, Union + +LIB_ROOT = pathlib.Path(__file__).parent / "lib" / "python" +sys.path.insert(0, os.fspath(LIB_ROOT)) + +import tomli +from importlib_metadata import metadata +from packaging.requirements import Requirement + +DEFAULT_SEVERITY = 3 + + +def parse_args(argv: Optional[Sequence[str]] = None): + if argv is None: + argv = sys.argv[1:] + parser = argparse.ArgumentParser( + description="Check for installed packages against requirements" + ) + parser.add_argument("FILEPATH", type=str, help="Path to requirements.[txt, in]") + + return parser.parse_args(argv) + + +def parse_requirements(line: str) -> Optional[Requirement]: + try: + req = Requirement(line.strip("\\")) + if req.marker is None: + return req + elif req.marker.evaluate(): + return req + except: + return None + + +def process_requirements(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]]]: + diagnostics = [] + for n, line in enumerate(req_file.read_text(encoding="utf-8").splitlines()): + if line.startswith(("#", "-", " ")) or line == "": + continue + + req = parse_requirements(line) + if req: + try: + # Check if package is installed + metadata(req.name) + except: + diagnostics.append( + { + "line": n, + "character": 0, + "endLine": n, + "endCharacter": len(req.name), + "package": req.name, + "code": "not-installed", + "severity": DEFAULT_SEVERITY, + } + ) + return diagnostics + + +def get_pos(lines: List[str], text: str) -> Tuple[int, int, int, int]: + for n, line in enumerate(lines): + index = line.find(text) + if index >= 0: + return n, index, n, index + len(text) + return (0, 0, 0, 0) + + +def process_pyproject(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]]]: + diagnostics = [] + try: + raw_text = req_file.read_text(encoding="utf-8") + pyproject = tomli.loads(raw_text) + except: + return diagnostics + + lines = raw_text.splitlines() + reqs = pyproject.get("project", {}).get("dependencies", []) + for raw_req in reqs: + req = parse_requirements(raw_req) + n, start, _, end = get_pos(lines, raw_req) + if req: + try: + # Check if package is installed + metadata(req.name) + except: + diagnostics.append( + { + "line": n, + "character": start, + "endLine": n, + "endCharacter": end, + "package": req.name, + "code": "not-installed", + "severity": DEFAULT_SEVERITY, + } + ) + return diagnostics + + +def get_diagnostics(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]]]: + diagnostics = [] + if not req_file.exists(): + return diagnostics + + if req_file.name == "pyproject.toml": + diagnostics = process_pyproject(req_file) + else: + diagnostics = process_requirements(req_file) + + return diagnostics + + +def main(): + args = parse_args() + diagnostics = get_diagnostics(pathlib.Path(args.FILEPATH)) + print(json.dumps(diagnostics, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/pythonFiles/tests/test_data/missing-deps.data b/pythonFiles/tests/test_data/missing-deps.data new file mode 100644 index 000000000000..c42d23c7dd67 --- /dev/null +++ b/pythonFiles/tests/test_data/missing-deps.data @@ -0,0 +1,121 @@ +# +# This file is autogenerated by pip-compile with Python 3.7 +# by the following command: +# +# pip-compile --generate-hashes --resolver=backtracking requirements-test.in +# +flake8-csv==0.2.0 \ + --hash=sha256:246e07207fefbf8f80a59ff7e878f153635f562ebaf20cf796a2b00b1528ea9a \ + --hash=sha256:bf3ac6aecbaebe36a2c7d5d275f310996fcc33b7370cdd81feec04b79af2e07c + # via -r requirements-test.in +levenshtein==0.21.0 \ + --hash=sha256:01dd427cf72b4978b09558e3d36e3f92c8eef467e3eb4653c3fdccd8d70aaa08 \ + --hash=sha256:0236c8ff4648c50ebd81ac3692430d2241b134936ac9d86d7ca32ba6ab4a4e63 \ + --hash=sha256:023ca95c833ca548280e444e9a4c34fdecb3be3851e96af95bad290ae0c708b9 \ + --hash=sha256:024302c82d49fc1f1d044794997ef7aa9d01b509a9040e222480b64a01cd4b80 \ + --hash=sha256:04046878a57129da4e2352c032df7c1fceaa54870916d12772cad505ef998290 \ + --hash=sha256:04850a0719e503014acb3fee6d4ec7d7f170a2c7375ffbc5833c7256b7cd10ee \ + --hash=sha256:0cc3679978cd0250bf002963cf2e08855b93f70fa0fc9f74956115c343983fbb \ + --hash=sha256:0f42b8dba2cce257cd34efd1ce9678d06f3248cb0bb2a92a5db8402e1e4a6f30 \ + --hash=sha256:13e8a5b1b58de49befea555bb913dc394614f2d3553bc5b86bc672c69ef1a85a \ + --hash=sha256:1f19fe25ea0dd845d0f48505e8947f6080728e10b7642ba0dad34e9b48c81130 \ + --hash=sha256:1fde464f937878e6f5c30c234b95ce2cb969331a175b3089367e077113428062 \ + --hash=sha256:2290732763e3b75979888364b26acce79d72b8677441b5762a4e97b3630cc3d9 \ + --hash=sha256:24843f28cbbdcbcfc18b08e7d3409dbaad7896fb7113442592fa978590a7bbf0 \ + --hash=sha256:25576ad9c337ecb342306fe87166b54b2f49e713d4ff592c752cc98e0046296e \ + --hash=sha256:26c6fb012538a245d78adea786d2cfe3c1506b835762c1c523a4ed6b9e08dc0b \ + --hash=sha256:31cb59d86a5f99147cd4a67ebced8d6df574b5d763dcb63c033a642e29568746 \ + --hash=sha256:32dfda2e64d0c50553e47d0ab2956413970f940253351c196827ad46f17916d5 \ + --hash=sha256:3305262cb85ff78ace9e2d8d2dfc029b34dc5f93aa2d24fd20b6ed723e2ad501 \ + --hash=sha256:37a99d858fa1d88b1a917b4059a186becd728534e5e889d583086482356b7ca1 \ + --hash=sha256:3c6858cfd84568bc1df3ad545553b5c27af6ed3346973e8f4b57d23c318cf8f4 \ + --hash=sha256:3e1723d515ab287b9b2c2e4a111894dc6b474f5d28826fff379647486cae98d2 \ + --hash=sha256:3e22d31375d5fea5797c9b7aa0f8cc36579c31dcf5754e9931ca86c27d9011f8 \ + --hash=sha256:426883be613d912495cf6ee2a776d2ab84aa6b3de5a8d82c43a994267ea6e0e3 \ + --hash=sha256:4357bf8146cbadb10016ad3a950bba16e042f79015362a575f966181d95b4bc7 \ + --hash=sha256:4515f9511cb91c66d254ee30154206aad76b57d8b25f64ba1402aad43efdb251 \ + --hash=sha256:457442911df185e28a32fd8b788b14ca22ab3a552256b556e7687173d5f18bc4 \ + --hash=sha256:46dab8c6e8fae563ca77acfaeb3824c4dd4b599996328b8a081b06f16befa6a0 \ + --hash=sha256:4b2156f32e46d16b74a055ccb4f64ee3c64399372a6aaf1ee98f6dccfadecee1 \ + --hash=sha256:4bbceef2caba4b2ae613b0e853a7aaab990c1a13bddb9054ba1328a84bccdbf7 \ + --hash=sha256:4c8eaaa6f0df2838437d1d8739629486b145f7a3405d3ef0874301a9f5bc7dcd \ + --hash=sha256:4dc79033140f82acaca40712a6d26ed190cc2dd403e104020a87c24f2771aa72 \ + --hash=sha256:4ec2ef9836a34a3bb009a81e5efe4d9d43515455fb5f182c5d2cf8ae61c79496 \ + --hash=sha256:5369827ace536c6df04e0e670d782999bc17bf9eb111e77435fdcdaecb10c2a3 \ + --hash=sha256:5378a8139ba61d7271c0f9350201259c11eb90bfed0ac45539c4aeaed3907230 \ + --hash=sha256:545635d9e857711d049dcdb0b8609fb707b34b032517376c531ca159fcd46265 \ + --hash=sha256:587ad51770de41eb491bea1bfb676abc7ff9a94dbec0e2bc51fc6a25abef99c4 \ + --hash=sha256:5cfbc4ed7ee2965e305bf81388fea377b795dabc82ee07f04f31d1fb8677a885 \ + --hash=sha256:5e748c2349719cb1bc90f802d9d7f07310633dcf166d468a5bd821f78ed17698 \ + --hash=sha256:608beb1683508c3cdbfff669c1c872ea02b47965e1bbb8a630de548e2490f96a \ + --hash=sha256:6338a47b6f8c7f1ee8b5636cc8b245ad2d1d0ee47f7bb6f33f38a522ef0219cc \ + --hash=sha256:668ea30b311944c643f866ce5e45edf346f05e920075c0056f2ba7f74dde6071 \ + --hash=sha256:66d303cd485710fe6d62108209219b7a695bdd10a722f4e86abdaf26f4bf2202 \ + --hash=sha256:6ebabcf982ae161534f8729d13fe05eebc977b497ac34936551f97cf8b07dd9e \ + --hash=sha256:6ede583155f24c8b2456a7720fbbfa5d9c1154ae04b4da3cf63368e2406ea099 \ + --hash=sha256:709a727f58d31a5ee1e5e83b247972fe55ef0014f6222256c9692c5efa471785 \ + --hash=sha256:742b785c93d16c63289902607219c200bd2b6077dafc788073c74337cae382fb \ + --hash=sha256:76d5d34a8e21de8073c66ae801f053520f946d499fa533fbba654712775f8132 \ + --hash=sha256:7bc550d0986ace95bde003b8a60e622449baf2bdf24d8412f7a50f401a289ec3 \ + --hash=sha256:7c2d67220867d640e36931b3d63b8349369b485d52cf6f4a2635bec8da92d678 \ + --hash=sha256:7ce3f14a8e006fb7e3fc7bab965ab7da5817f48fc48d25cf735fcec8f1d2e39a \ + --hash=sha256:7e40a4bac848c9a8883225f926cfa7b2bc9f651e989a8b7006cdb596edc7ac9b \ + --hash=sha256:80e67bd73a05592ecd52aede4afa8ea49575de70f9d5bfbe2c52ebd3541b20be \ + --hash=sha256:8446f8da38857482ec0cfd616fe5e7dcd3695fd323cc65f37366a9ff6a31c9cb \ + --hash=sha256:8476862a5c3150b8d63a7475563a4bff6dc50bbc0447894eb6b6a116ced0809d \ + --hash=sha256:84b55b732e311629a8308ad2778a0f9824e29e3c35987eb35610fc52eb6d4634 \ + --hash=sha256:88ccdc8dc20c16e8059ace00fb58d353346a04fd24c0733b009678b2554801d2 \ + --hash=sha256:8aa92b05156dfa2e248c3743670d5deb41a45b5789416d5fa31be009f4f043ab \ + --hash=sha256:8ac4ed77d3263eac7f9b6ed89d451644332aecd55cda921201e348803a1e5c57 \ + --hash=sha256:8bdbcd1570340b07549f71e8a5ba3f0a6d84408bf86c4051dc7b70a29ae342bb \ + --hash=sha256:8c031cbe3685b0343f5cc2dcf2172fd21b82f8ccc5c487179a895009bf0e4ea8 \ + --hash=sha256:8c27a5178ce322b56527a451185b4224217aa81955d9b0dad6f5a8de81ffe80f \ + --hash=sha256:8cf87a5e2962431d7260dd81dc1ca0697f61aad81036145d3666f4c0d514ce3a \ + --hash=sha256:8d4ba0df46bb41d660d77e7cc6b4d38c8d5b6f977d51c48ed1217db6a8474cde \ + --hash=sha256:8dd8ef4239b24fb1c9f0b536e48e55194d5966d351d349af23e67c9eb3875c68 \ + --hash=sha256:92bf2370b01d7a4862abf411f8f60f39f064cebebce176e3e9ee14e744db8288 \ + --hash=sha256:9485f2a5c88113410153256657072bc93b81bf5c8690d47e4cc3df58135dbadb \ + --hash=sha256:9ff1255c499fcb41ba37a578ad8c1b8dab5c44f78941b8e1c1d7fab5b5e831bc \ + --hash=sha256:a18c8e4d1aae3f9950797d049020c64a8a63cc8b4e43afcca91ec400bf6304c5 \ + --hash=sha256:a68b05614d25cc2a5fbcc4d2fd124be7668d075fd5ac3d82f292eec573157361 \ + --hash=sha256:a7adaabe07c5ceb6228332b9184f06eb9cda89c227d198a1b8a6f78c05b3c672 \ + --hash=sha256:aa39bb773915e4df330d311bb6c100a8613e265cc50d5b25b015c8db824e1c47 \ + --hash=sha256:ac8b6266799645827980ab1af4e0bfae209c1f747a10bdf6e5da96a6ebe511a2 \ + --hash=sha256:b0ba9723c7d67a61e160b3457259552f7d679d74aaa144b892eb68b7e2a5ebb6 \ + --hash=sha256:b167b32b3e336c5ec5e0212f025587f9248344ae6e73ed668270eba5c6a506e5 \ + --hash=sha256:b646ace5085a60d4f89b28c81301c9d9e8cd6a9bdda908181b2fa3dfac7fc10d \ + --hash=sha256:bd0bfa71b1441be359e99e77709885b79c22857bf9bb7f4e84c09e501f6c5fad \ + --hash=sha256:be038321695267a8faa5ae1b1a83deb3748827f0b6f72471e0beed36afcbd72a \ + --hash=sha256:be87998ffcbb5fb0c37a76d100f63b4811f48527192677da0ec3624b49ab8a64 \ + --hash=sha256:c270487d60b33102efea73be6dcd5835f3ddc3dc06e77499f0963df6cba2ec71 \ + --hash=sha256:c290a7211f1b4f87c300df4424cc46b7379cead3b6f37fa8d3e7e6c6212ccd39 \ + --hash=sha256:cc36ba40027b4f8821155c9e3e0afadffccdccbe955556039d1d1169dfc659c9 \ + --hash=sha256:ce7e76c6341abb498368d42b8081f2f45c245ac2a221af6a0394349d41302c08 \ + --hash=sha256:cefd5a668f6d7af1279aca10104b43882fdd83f9bdc68933ba5429257a628abe \ + --hash=sha256:cf2dee0f8c71598f8be51e3feceb9142ac01576277b9e691e25740987761c86e \ + --hash=sha256:d23c647b03acbb5783f9bdfd51cfa5365d51f7df9f4029717a35eff5cc32bbcc \ + --hash=sha256:d647f1e0c30c7a73f70f4de7376ed7dafc2b856b67fe480d32a81af133edbaeb \ + --hash=sha256:d932cb21e40beb93cfc8973de7f25fbf25ba4a07d1dccac3b9ba977164cf9887 \ + --hash=sha256:db7567997ffbc2feb999e30002a92461a76f17a596a142bdb463b5f7037f160c \ + --hash=sha256:de2dfd6498454c7d89036d56a53c0a01fd9bcf1c2970253e469b5e8bb938b69f \ + --hash=sha256:df9b0f8f511270ad259c7bfba22ab6d5a0c33d81cd594461668e67cd80dd9052 \ + --hash=sha256:e043b79e39f165026bc941c95582bfc4bfdd297a1de6f13ace0d0a7abf486288 \ + --hash=sha256:e2686c37d22faf27d02a19e83b55812d248b32b7ba3aa638e768d0ea032e1f3c \ + --hash=sha256:e9a6251818b9eb6d519bffd7a0b745f3a99b3e99563a4c9d3cad26e34f6ac880 \ + --hash=sha256:eab6c253983a6659e749f4c44fcc2215194c2e00bf7b1c5e90fe683ea3b7b00f \ + --hash=sha256:ec64b7b3fb95bc9c20c72548277794b81281a6ba9da85eda2c87324c218441ff \ + --hash=sha256:ee62ec5882a857b252faffeb7867679f7e418052ca6bf7d6b56099f6498a2b0e \ + --hash=sha256:ee757fd36bad66ad8b961958840894021ecaad22194f65219a666432739393ff \ + --hash=sha256:f55623094b665d79a3b82ba77386ac34fa85049163edfe65387063e5127d4184 \ + --hash=sha256:f622f542bd065ffec7d26b26d44d0c9a25c9c1295fd8ba6e4d77778e2293a12c \ + --hash=sha256:f873af54014cac12082c7f5ccec6bbbeb5b57f63466e7f9c61a34588621313fb \ + --hash=sha256:fae24c875c4ecc8c5f34a9715eb2a459743b4ca21d35c51819b640ee2f71cb51 \ + --hash=sha256:fb26e69fc6c12534fbaa1657efed3b6482f1a166ba8e31227fa6f6f062a59070 + # via -r requirements-test.in +pytest==7.3.1 \ + --hash=sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362 \ + --hash=sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3 + +tomli==2.0.1 \ + --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ + --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f diff --git a/pythonFiles/tests/test_data/no-missing-deps.data b/pythonFiles/tests/test_data/no-missing-deps.data new file mode 100644 index 000000000000..5c2f1178bbdf --- /dev/null +++ b/pythonFiles/tests/test_data/no-missing-deps.data @@ -0,0 +1,13 @@ +# +# This file is autogenerated by pip-compile with Python 3.7 +# by the following command: +# +# pip-compile --generate-hashes --resolver=backtracking requirements-test.in +# +pytest==7.3.1 \ + --hash=sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362 \ + --hash=sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3 + +tomli==2.0.1 \ + --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ + --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f diff --git a/pythonFiles/tests/test_data/pyproject-missing-deps.data b/pythonFiles/tests/test_data/pyproject-missing-deps.data new file mode 100644 index 000000000000..f217a0bdade6 --- /dev/null +++ b/pythonFiles/tests/test_data/pyproject-missing-deps.data @@ -0,0 +1,9 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "something" +version = "2023.0.0" +requires-python = ">=3.7" +dependencies = ["pytest==7.3.1", "flake8-csv"] diff --git a/pythonFiles/tests/test_data/pyproject-no-missing-deps.data b/pythonFiles/tests/test_data/pyproject-no-missing-deps.data new file mode 100644 index 000000000000..729bc9169e6f --- /dev/null +++ b/pythonFiles/tests/test_data/pyproject-no-missing-deps.data @@ -0,0 +1,9 @@ +[build-system] +requires = ["flit_core >=3.2,<4"] +build-backend = "flit_core.buildapi" + +[project] +name = "something" +version = "2023.0.0" +requires-python = ">=3.7" +dependencies = [jedi-language-server"] diff --git a/pythonFiles/tests/test_installed_check.py b/pythonFiles/tests/test_installed_check.py new file mode 100644 index 000000000000..f76070d197be --- /dev/null +++ b/pythonFiles/tests/test_installed_check.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import contextlib +import json +import os +import pathlib +import subprocess +import sys + +import pytest +from typing import Dict, List, Union + +SCRIPT_PATH = pathlib.Path(__file__).parent.parent / "installed_check.py" +TEST_DATA = pathlib.Path(__file__).parent / "test_data" +DEFAULT_SEVERITY = 3 + + +@contextlib.contextmanager +def generate_file(base_file: pathlib.Path): + basename = "pyproject.toml" if "pyproject" in base_file.name else "requirements.txt" + fullpath = base_file.parent / basename + if fullpath.exists(): + os.unlink(os.fspath(fullpath)) + fullpath.write_text(base_file.read_text(encoding="utf-8")) + try: + yield fullpath + finally: + os.unlink(str(fullpath)) + + +def run_on_file(file_path: pathlib.Path) -> List[Dict[str, Union[str, int]]]: + result = subprocess.run( + [ + sys.executable, + os.fspath(SCRIPT_PATH), + os.fspath(file_path), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + assert result.returncode == 0 + assert result.stderr == b"" + return json.loads(result.stdout) + + +EXPECTED_DATA = { + "missing-deps": [ + { + "line": 6, + "character": 0, + "endLine": 6, + "endCharacter": 10, + "package": "flake8-csv", + "code": "not-installed", + "severity": 3, + }, + { + "line": 10, + "character": 0, + "endLine": 10, + "endCharacter": 11, + "package": "levenshtein", + "code": "not-installed", + "severity": 3, + }, + ], + "no-missing-deps": [], + "pyproject-missing-deps": [ + { + "line": 8, + "character": 34, + "endLine": 8, + "endCharacter": 44, + "package": "flake8-csv", + "code": "not-installed", + "severity": 3, + } + ], + "pyproject-no-missing-deps": [], +} + + +@pytest.mark.parametrize("test_name", EXPECTED_DATA.keys()) +def test_installed_check(test_name: str): + base_file = TEST_DATA / f"{test_name}.data" + with generate_file(base_file) as file_path: + result = run_on_file(file_path) + assert result == EXPECTED_DATA[test_name] diff --git a/requirements.in b/requirements.in index 8b76e392917e..bdd5e10dfc47 100644 --- a/requirements.in +++ b/requirements.in @@ -8,3 +8,8 @@ typing-extensions==4.5.0 # Fallback env creator for debian microvenv + +# Checker for installed packages +importlib_metadata +packaging +tomli diff --git a/requirements.txt b/requirements.txt index 07145e1832d5..1419e0528ccc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,11 +4,29 @@ # # pip-compile --generate-hashes requirements.in # +importlib-metadata==6.6.0 \ + --hash=sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed \ + --hash=sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705 + # via -r requirements.in microvenv==2023.2.0 \ --hash=sha256:5b46296d6a65992946da504bd9e724a5becf5c256091f2f9383e5b4e9f567f23 \ --hash=sha256:a07e88a8fb5ee90219b86dd90095cb5646462d45d30285ea3b1a3c7cf33616d3 # via -r requirements.in +packaging==23.1 \ + --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ + --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f + # via -r requirements.in +tomli==2.0.1 \ + --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ + --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f + # via -r requirements.in typing-extensions==4.5.0 \ --hash=sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb \ --hash=sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4 - # via -r requirements.in + # via + # -r requirements.in + # importlib-metadata +zipp==3.15.0 \ + --hash=sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b \ + --hash=sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556 + # via importlib-metadata diff --git a/src/client/common/process/internal/scripts/index.ts b/src/client/common/process/internal/scripts/index.ts index c52983d9910b..7240d7be67f8 100644 --- a/src/client/common/process/internal/scripts/index.ts +++ b/src/client/common/process/internal/scripts/index.ts @@ -149,3 +149,8 @@ export function createCondaScript(): string { const script = path.join(SCRIPTS_DIR, 'create_conda.py'); return script; } + +export function installedCheckScript(): string { + const script = path.join(SCRIPTS_DIR, 'installed_check.py'); + return script; +} diff --git a/src/client/common/vscodeApis/languageApis.ts b/src/client/common/vscodeApis/languageApis.ts new file mode 100644 index 000000000000..87681507693d --- /dev/null +++ b/src/client/common/vscodeApis/languageApis.ts @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { DiagnosticChangeEvent, DiagnosticCollection, Disposable, languages } from 'vscode'; + +export function createDiagnosticCollection(name: string): DiagnosticCollection { + return languages.createDiagnosticCollection(name); +} + +export function onDidChangeDiagnostics(handler: (e: DiagnosticChangeEvent) => void): Disposable { + return languages.onDidChangeDiagnostics(handler); +} diff --git a/src/client/common/vscodeApis/windowApis.ts b/src/client/common/vscodeApis/windowApis.ts index 5c279b890a9f..1c242314cb87 100644 --- a/src/client/common/vscodeApis/windowApis.ts +++ b/src/client/common/vscodeApis/windowApis.ts @@ -77,6 +77,10 @@ export function getActiveTextEditor(): TextEditor | undefined { return activeTextEditor; } +export function onDidChangeActiveTextEditor(handler: (e: TextEditor | undefined) => void): Disposable { + return window.onDidChangeActiveTextEditor(handler); +} + export enum MultiStepAction { Back = 'Back', Cancel = 'Cancel', diff --git a/src/client/common/vscodeApis/workspaceApis.ts b/src/client/common/vscodeApis/workspaceApis.ts index 20528c17ec11..0e860743a32d 100644 --- a/src/client/common/vscodeApis/workspaceApis.ts +++ b/src/client/common/vscodeApis/workspaceApis.ts @@ -36,12 +36,12 @@ export function findFiles( return vscode.workspace.findFiles(include, exclude, maxResults, token); } -export function onDidSaveTextDocument( - listener: (e: vscode.TextDocument) => unknown, - thisArgs?: unknown, - disposables?: vscode.Disposable[], -): vscode.Disposable { - return vscode.workspace.onDidSaveTextDocument(listener, thisArgs, disposables); +export function onDidCloseTextDocument(handler: (e: vscode.TextDocument) => unknown): vscode.Disposable { + return vscode.workspace.onDidCloseTextDocument(handler); +} + +export function onDidSaveTextDocument(handler: (e: vscode.TextDocument) => unknown): vscode.Disposable { + return vscode.workspace.onDidSaveTextDocument(handler); } export function getOpenTextDocuments(): readonly vscode.TextDocument[] { diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 542a6ccc3010..8dcea063676a 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -51,10 +51,9 @@ import { DebugSessionEventDispatcher } from './debugger/extension/hooks/eventHan import { IDebugSessionEventHandlers } from './debugger/extension/hooks/types'; import { WorkspaceService } from './common/application/workspace'; import { DynamicPythonDebugConfigurationService } from './debugger/extension/configuration/dynamicdebugConfigurationService'; -import { registerCreateEnvironmentFeatures } from './pythonEnvironments/creation/createEnvApi'; import { IInterpreterQuickPick } from './interpreter/configuration/types'; import { registerInstallFormatterPrompt } from './providers/prompts/installFormatterPrompt'; -import { registerCreateEnvButtonFeatures } from './pythonEnvironments/creation/createEnvButtonContext'; +import { registerAllCreateEnvironmentFeatures } from './pythonEnvironments/creation/registrations'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -97,8 +96,7 @@ export function activateFeatures(ext: ExtensionState, _components: Components): IInterpreterPathService, ); const pathUtils = ext.legacyIOC.serviceContainer.get(IPathUtils); - registerCreateEnvironmentFeatures(ext.disposables, interpreterQuickPick, interpreterPathService, pathUtils); - registerCreateEnvButtonFeatures(ext.disposables); + registerAllCreateEnvironmentFeatures(ext.disposables, interpreterQuickPick, interpreterPathService, pathUtils); } /// ////////////////////////// diff --git a/src/client/pythonEnvironments/creation/common/installCheckUtils.ts b/src/client/pythonEnvironments/creation/common/installCheckUtils.ts new file mode 100644 index 000000000000..8c9817f83b7a --- /dev/null +++ b/src/client/pythonEnvironments/creation/common/installCheckUtils.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { Diagnostic, DiagnosticSeverity, l10n, Range, TextDocument, Uri } from 'vscode'; +import { installedCheckScript } from '../../../common/process/internal/scripts'; +import { plainExec } from '../../../common/process/rawProcessApis'; +import { IInterpreterPathService } from '../../../common/types'; +import { traceInfo, traceVerbose, traceError } from '../../../logging'; + +interface PackageDiagnostic { + package: string; + line: number; + character: number; + endLine: number; + endCharacter: number; + code: string; + severity: DiagnosticSeverity; +} + +export const INSTALL_CHECKER_SOURCE = 'Python-InstalledPackagesChecker'; + +function parseDiagnostics(data: string): Diagnostic[] { + let diagnostics: Diagnostic[] = []; + try { + const raw = JSON.parse(data) as PackageDiagnostic[]; + diagnostics = raw.map((item) => { + const d = new Diagnostic( + new Range(item.line, item.character, item.endLine, item.endCharacter), + l10n.t(`Package \`${item.package}\` is not installed in the selected environment.`), + item.severity, + ); + d.code = { value: item.code, target: Uri.parse(`https://pypi.org/p/${item.package}`) }; + d.source = INSTALL_CHECKER_SOURCE; + return d; + }); + } catch { + diagnostics = []; + } + return diagnostics; +} + +export async function getInstalledPackagesDiagnostics( + interpreterPathService: IInterpreterPathService, + doc: TextDocument, +): Promise { + const interpreter = interpreterPathService.get(doc.uri); + const scriptPath = installedCheckScript(); + try { + traceInfo('Running installed packages checker: ', interpreter, scriptPath, doc.uri.fsPath); + const result = await plainExec(interpreter, [scriptPath, doc.uri.fsPath]); + traceVerbose('Installed packages check result:\n', result.stdout); + if (result.stderr) { + traceError('Installed packages check error:\n', result.stderr); + } + return parseDiagnostics(result.stdout); + } catch (ex) { + traceError('Error while getting installed packages check result:\n', ex); + } + return []; +} diff --git a/src/client/pythonEnvironments/creation/createEnvButtonContext.ts b/src/client/pythonEnvironments/creation/createEnvButtonContext.ts index cd009bc6118a..4ce7d07ad69d 100644 --- a/src/client/pythonEnvironments/creation/createEnvButtonContext.ts +++ b/src/client/pythonEnvironments/creation/createEnvButtonContext.ts @@ -1,25 +1,9 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { TextDocument, TextDocumentChangeEvent } from 'vscode'; import { IDisposableRegistry } from '../../common/types'; import { executeCommand } from '../../common/vscodeApis/commandApis'; -import { - onDidOpenTextDocument, - onDidChangeTextDocument, - getOpenTextDocuments, - getConfiguration, - onDidChangeConfiguration, -} from '../../common/vscodeApis/workspaceApis'; -import { isPipInstallableToml } from './provider/venvUtils'; - -async function setPyProjectTomlContextKey(doc: TextDocument): Promise { - if (isPipInstallableToml(doc.getText())) { - await executeCommand('setContext', 'pipInstallableToml', true); - } else { - await executeCommand('setContext', 'pipInstallableToml', false); - } -} +import { getConfiguration, onDidChangeConfiguration } from '../../common/vscodeApis/workspaceApis'; async function setShowCreateEnvButtonContextKey(): Promise { const config = getConfiguration('python'); @@ -27,32 +11,12 @@ async function setShowCreateEnvButtonContextKey(): Promise { await executeCommand('setContext', 'showCreateEnvButton', showCreateEnvButton); } -export function registerCreateEnvButtonFeatures(disposables: IDisposableRegistry): void { +export function registerCreateEnvironmentButtonFeatures(disposables: IDisposableRegistry): void { disposables.push( - onDidOpenTextDocument(async (doc: TextDocument) => { - if (doc.fileName.endsWith('pyproject.toml')) { - await setPyProjectTomlContextKey(doc); - } - }), - onDidChangeTextDocument(async (e: TextDocumentChangeEvent) => { - const doc = e.document; - if (doc.fileName.endsWith('pyproject.toml')) { - await setPyProjectTomlContextKey(doc); - } - }), onDidChangeConfiguration(async () => { await setShowCreateEnvButtonContextKey(); }), ); setShowCreateEnvButtonContextKey(); - - const docs = getOpenTextDocuments().filter( - (doc) => doc.fileName.endsWith('pyproject.toml') && isPipInstallableToml(doc.getText()), - ); - if (docs.length > 0) { - executeCommand('setContext', 'pipInstallableToml', true); - } else { - executeCommand('setContext', 'pipInstallableToml', false); - } } diff --git a/src/client/pythonEnvironments/creation/installedPackagesDiagnostic.ts b/src/client/pythonEnvironments/creation/installedPackagesDiagnostic.ts new file mode 100644 index 000000000000..a46a32ce8276 --- /dev/null +++ b/src/client/pythonEnvironments/creation/installedPackagesDiagnostic.ts @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { Diagnostic, DiagnosticCollection, TextDocument, Uri } from 'vscode'; +import { IDisposableRegistry, IInterpreterPathService } from '../../common/types'; +import { executeCommand } from '../../common/vscodeApis/commandApis'; +import { createDiagnosticCollection, onDidChangeDiagnostics } from '../../common/vscodeApis/languageApis'; +import { getActiveTextEditor, onDidChangeActiveTextEditor } from '../../common/vscodeApis/windowApis'; +import { + getOpenTextDocuments, + onDidCloseTextDocument, + onDidOpenTextDocument, + onDidSaveTextDocument, +} from '../../common/vscodeApis/workspaceApis'; +import { traceVerbose } from '../../logging'; +import { getInstalledPackagesDiagnostics, INSTALL_CHECKER_SOURCE } from './common/installCheckUtils'; + +export const DEPS_NOT_INSTALLED_KEY = 'pythonDepsNotInstalled'; + +async function setContextForActiveEditor(diagnosticCollection: DiagnosticCollection): Promise { + const doc = getActiveTextEditor()?.document; + if (doc && (doc.languageId === 'pip-requirements' || doc.fileName.endsWith('pyproject.toml'))) { + const diagnostics = diagnosticCollection.get(doc.uri); + if (diagnostics && diagnostics.length > 0) { + traceVerbose(`Setting context for python dependencies not installed: ${doc.uri.fsPath}`); + await executeCommand('setContext', DEPS_NOT_INSTALLED_KEY, true); + return; + } + } + + // undefined here in the logs means no file was selected + traceVerbose(`Clearing context for python dependencies not installed: ${doc?.uri.fsPath}`); + await executeCommand('setContext', DEPS_NOT_INSTALLED_KEY, false); +} + +export function registerInstalledPackagesDiagnosticsProvider( + disposables: IDisposableRegistry, + interpreterPathService: IInterpreterPathService, +): void { + const diagnosticCollection = createDiagnosticCollection(INSTALL_CHECKER_SOURCE); + const updateDiagnostics = (uri: Uri, diagnostics: Diagnostic[]) => { + if (diagnostics.length > 0) { + diagnosticCollection.set(uri, diagnostics); + } else if (diagnosticCollection.has(uri)) { + diagnosticCollection.delete(uri); + } + }; + + disposables.push(diagnosticCollection); + disposables.push( + onDidOpenTextDocument(async (doc: TextDocument) => { + if (doc.languageId === 'pip-requirements' || doc.fileName.endsWith('pyproject.toml')) { + const diagnostics = await getInstalledPackagesDiagnostics(interpreterPathService, doc); + updateDiagnostics(doc.uri, diagnostics); + } + }), + onDidSaveTextDocument(async (doc: TextDocument) => { + if (doc.languageId === 'pip-requirements' || doc.fileName.endsWith('pyproject.toml')) { + const diagnostics = await getInstalledPackagesDiagnostics(interpreterPathService, doc); + updateDiagnostics(doc.uri, diagnostics); + } + }), + onDidCloseTextDocument((e: TextDocument) => { + updateDiagnostics(e.uri, []); + }), + onDidChangeDiagnostics(async () => { + await setContextForActiveEditor(diagnosticCollection); + }), + onDidChangeActiveTextEditor(async () => { + await setContextForActiveEditor(diagnosticCollection); + }), + interpreterPathService.onDidChange(() => { + getOpenTextDocuments().forEach(async (doc: TextDocument) => { + if (doc.languageId === 'pip-requirements' || doc.fileName.endsWith('pyproject.toml')) { + const diagnostics = await getInstalledPackagesDiagnostics(interpreterPathService, doc); + updateDiagnostics(doc.uri, diagnostics); + } + }); + }), + ); + + getOpenTextDocuments().forEach(async (doc: TextDocument) => { + if (doc.languageId === 'pip-requirements' || doc.fileName.endsWith('pyproject.toml')) { + const diagnostics = await getInstalledPackagesDiagnostics(interpreterPathService, doc); + updateDiagnostics(doc.uri, diagnostics); + } + }); +} diff --git a/src/client/pythonEnvironments/creation/pyProjectTomlContext.ts b/src/client/pythonEnvironments/creation/pyProjectTomlContext.ts new file mode 100644 index 000000000000..5925b7641f45 --- /dev/null +++ b/src/client/pythonEnvironments/creation/pyProjectTomlContext.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { TextDocument } from 'vscode'; +import { IDisposableRegistry } from '../../common/types'; +import { executeCommand } from '../../common/vscodeApis/commandApis'; +import { + onDidOpenTextDocument, + onDidSaveTextDocument, + getOpenTextDocuments, +} from '../../common/vscodeApis/workspaceApis'; +import { isPipInstallableToml } from './provider/venvUtils'; + +async function setPyProjectTomlContextKey(doc: TextDocument): Promise { + if (isPipInstallableToml(doc.getText())) { + await executeCommand('setContext', 'pipInstallableToml', true); + } else { + await executeCommand('setContext', 'pipInstallableToml', false); + } +} + +export function registerPyProjectTomlFeatures(disposables: IDisposableRegistry): void { + disposables.push( + onDidOpenTextDocument(async (doc: TextDocument) => { + if (doc.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(doc); + } + }), + onDidSaveTextDocument(async (doc: TextDocument) => { + if (doc.fileName.endsWith('pyproject.toml')) { + await setPyProjectTomlContextKey(doc); + } + }), + ); + + const docs = getOpenTextDocuments().filter( + (doc) => doc.fileName.endsWith('pyproject.toml') && isPipInstallableToml(doc.getText()), + ); + if (docs.length > 0) { + executeCommand('setContext', 'pipInstallableToml', true); + } else { + executeCommand('setContext', 'pipInstallableToml', false); + } +} diff --git a/src/client/pythonEnvironments/creation/registrations.ts b/src/client/pythonEnvironments/creation/registrations.ts new file mode 100644 index 000000000000..eeb04036bc1b --- /dev/null +++ b/src/client/pythonEnvironments/creation/registrations.ts @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { IDisposableRegistry, IInterpreterPathService, IPathUtils } from '../../common/types'; +import { IInterpreterQuickPick } from '../../interpreter/configuration/types'; +import { registerCreateEnvironmentFeatures } from './createEnvApi'; +import { registerCreateEnvironmentButtonFeatures } from './createEnvButtonContext'; +import { registerInstalledPackagesDiagnosticsProvider } from './installedPackagesDiagnostic'; +import { registerPyProjectTomlFeatures } from './pyProjectTomlContext'; + +export function registerAllCreateEnvironmentFeatures( + disposables: IDisposableRegistry, + interpreterQuickPick: IInterpreterQuickPick, + interpreterPathService: IInterpreterPathService, + pathUtils: IPathUtils, +): void { + registerCreateEnvironmentFeatures(disposables, interpreterQuickPick, interpreterPathService, pathUtils); + registerCreateEnvironmentButtonFeatures(disposables); + registerPyProjectTomlFeatures(disposables); + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService); +} diff --git a/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts b/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts new file mode 100644 index 000000000000..de8e263fc3fe --- /dev/null +++ b/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { assert, use as chaiUse } from 'chai'; +import { Diagnostic, TextDocument, Range, Uri } from 'vscode'; +import * as rawProcessApis from '../../../../client/common/process/rawProcessApis'; +import { getInstalledPackagesDiagnostics } from '../../../../client/pythonEnvironments/creation/common/installCheckUtils'; +import { IInterpreterPathService } from '../../../../client/common/types'; + +chaiUse(chaiAsPromised); + +function getSomeRequirementFile(): typemoq.IMock { + const someFilePath = 'requirements.txt'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.languageId).returns(() => 'pip-requirements'); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'flake8-csv'); + return someFile; +} + +const MISSING_PACKAGES_STR = + '[{"line": 8, "character": 34, "endLine": 8, "endCharacter": 44, "package": "flake8-csv", "code": "not-installed", "severity": 3}]'; +const MISSING_PACKAGES: Diagnostic[] = [ + { + range: new Range(8, 34, 8, 44), + message: 'Package `flake8-csv` is not installed in the selected environment.', + source: 'Python-InstalledPackagesChecker', + code: { value: 'not-installed', target: Uri.parse(`https://pypi.org/p/flake8-csv`) }, + severity: 3, + relatedInformation: [], + }, +]; + +suite('Install check diagnostics tests', () => { + let plainExecStub: sinon.SinonStub; + let interpreterPathService: typemoq.IMock; + + setup(() => { + plainExecStub = sinon.stub(rawProcessApis, 'plainExec'); + interpreterPathService = typemoq.Mock.ofType(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Test parse diagnostics', async () => { + plainExecStub.resolves({ stdout: MISSING_PACKAGES_STR, stderr: '' }); + const someFile = getSomeRequirementFile(); + const result = await getInstalledPackagesDiagnostics(interpreterPathService.object, someFile.object); + + assert.deepStrictEqual(result, MISSING_PACKAGES); + }); + + test('Test parse empty diagnostics', async () => { + plainExecStub.resolves({ stdout: '', stderr: '' }); + const someFile = getSomeRequirementFile(); + const result = await getInstalledPackagesDiagnostics(interpreterPathService.object, someFile.object); + + assert.deepStrictEqual(result, []); + }); +}); diff --git a/src/test/pythonEnvironments/creation/createEnvButtonContext.unit.test.ts b/src/test/pythonEnvironments/creation/createEnvButtonContext.unit.test.ts index 31842420dd59..eec2d066aadb 100644 --- a/src/test/pythonEnvironments/creation/createEnvButtonContext.unit.test.ts +++ b/src/test/pythonEnvironments/creation/createEnvButtonContext.unit.test.ts @@ -1,16 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -/* eslint-disable @typescript-eslint/no-explicit-any */ import * as chaiAsPromised from 'chai-as-promised'; import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; import { assert, use as chaiUse } from 'chai'; -import { TextDocument, TextDocumentChangeEvent, WorkspaceConfiguration } from 'vscode'; +import { WorkspaceConfiguration } from 'vscode'; import * as cmdApis from '../../../client/common/vscodeApis/commandApis'; import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; import { IDisposableRegistry } from '../../../client/common/types'; -import { registerCreateEnvButtonFeatures } from '../../../client/pythonEnvironments/creation/createEnvButtonContext'; +import { registerCreateEnvironmentButtonFeatures } from '../../../client/pythonEnvironments/creation/createEnvButtonContext'; chaiUse(chaiAsPromised); @@ -20,57 +19,17 @@ class FakeDisposable { } } -function getInstallableToml(): typemoq.IMock { - const pyprojectTomlPath = 'pyproject.toml'; - const pyprojectToml = typemoq.Mock.ofType(); - pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); - pyprojectToml - .setup((p) => p.getText(typemoq.It.isAny())) - .returns( - () => - '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', - ); - return pyprojectToml; -} - -function getNonInstallableToml(): typemoq.IMock { - const pyprojectTomlPath = 'pyproject.toml'; - const pyprojectToml = typemoq.Mock.ofType(); - pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); - pyprojectToml - .setup((p) => p.getText(typemoq.It.isAny())) - .returns(() => '[project]\nname = "spam"\nversion = "2020.0.0"\n'); - return pyprojectToml; -} - -function getSomeFile(): typemoq.IMock { - const someFilePath = 'something.py'; - const someFile = typemoq.Mock.ofType(); - someFile.setup((p) => p.fileName).returns(() => someFilePath); - someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'print("Hello World")'); - return someFile; -} - -suite('PyProject.toml Create Env Features', () => { +suite('Create Env content button settings tests', () => { let executeCommandStub: sinon.SinonStub; const disposables: IDisposableRegistry = []; - let getOpenTextDocumentsStub: sinon.SinonStub; - let onDidOpenTextDocumentStub: sinon.SinonStub; - let onDidChangeTextDocumentStub: sinon.SinonStub; let onDidChangeConfigurationStub: sinon.SinonStub; let getConfigurationStub: sinon.SinonStub; let configMock: typemoq.IMock; setup(() => { executeCommandStub = sinon.stub(cmdApis, 'executeCommand'); - getOpenTextDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments'); - onDidOpenTextDocumentStub = sinon.stub(workspaceApis, 'onDidOpenTextDocument'); - onDidChangeTextDocumentStub = sinon.stub(workspaceApis, 'onDidChangeTextDocument'); getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); onDidChangeConfigurationStub = sinon.stub(workspaceApis, 'onDidChangeConfiguration'); - - onDidOpenTextDocumentStub.returns(new FakeDisposable()); - onDidChangeTextDocumentStub.returns(new FakeDisposable()); onDidChangeConfigurationStub.returns(new FakeDisposable()); configMock = typemoq.Mock.ofType(); @@ -84,29 +43,23 @@ suite('PyProject.toml Create Env Features', () => { }); test('python.createEnvironment.contentButton setting is set to "show", no files open', async () => { - getOpenTextDocumentsStub.returns([]); - - registerCreateEnvButtonFeatures(disposables); + registerCreateEnvironmentButtonFeatures(disposables); assert.ok(executeCommandStub.calledWithExactly('setContext', 'showCreateEnvButton', true)); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); }); test('python.createEnvironment.contentButton setting is set to "hide", no files open', async () => { configMock.reset(); configMock.setup((c) => c.get(typemoq.It.isAny(), typemoq.It.isAny())).returns(() => 'hide'); - getOpenTextDocumentsStub.returns([]); - registerCreateEnvButtonFeatures(disposables); + registerCreateEnvironmentButtonFeatures(disposables); assert.ok(executeCommandStub.calledWithExactly('setContext', 'showCreateEnvButton', false)); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); }); test('python.createEnvironment.contentButton setting changed from "hide" to "show"', async () => { configMock.reset(); configMock.setup((c) => c.get(typemoq.It.isAny(), typemoq.It.isAny())).returns(() => 'hide'); - getOpenTextDocumentsStub.returns([]); let handler: () => void = () => { /* do nothing */ @@ -116,9 +69,8 @@ suite('PyProject.toml Create Env Features', () => { return new FakeDisposable(); }); - registerCreateEnvButtonFeatures(disposables); + registerCreateEnvironmentButtonFeatures(disposables); assert.ok(executeCommandStub.calledWithExactly('setContext', 'showCreateEnvButton', false)); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); executeCommandStub.reset(); configMock.reset(); @@ -131,7 +83,6 @@ suite('PyProject.toml Create Env Features', () => { test('python.createEnvironment.contentButton setting changed from "show" to "hide"', async () => { configMock.reset(); configMock.setup((c) => c.get(typemoq.It.isAny(), typemoq.It.isAny())).returns(() => 'show'); - getOpenTextDocumentsStub.returns([]); let handler: () => void = () => { /* do nothing */ @@ -141,9 +92,8 @@ suite('PyProject.toml Create Env Features', () => { return new FakeDisposable(); }); - registerCreateEnvButtonFeatures(disposables); + registerCreateEnvironmentButtonFeatures(disposables); assert.ok(executeCommandStub.calledWithExactly('setContext', 'showCreateEnvButton', true)); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); executeCommandStub.reset(); configMock.reset(); @@ -152,195 +102,4 @@ suite('PyProject.toml Create Env Features', () => { assert.ok(executeCommandStub.calledWithExactly('setContext', 'showCreateEnvButton', false)); }); - - test('Installable pyproject.toml is already open in the editor on extension activate', async () => { - const pyprojectToml = getInstallableToml(); - getOpenTextDocumentsStub.returns([pyprojectToml.object]); - - registerCreateEnvButtonFeatures(disposables); - - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', true)); - }); - - test('Non installable pyproject.toml is already open in the editor on extension activate', async () => { - const pyprojectToml = getNonInstallableToml(); - getOpenTextDocumentsStub.returns([pyprojectToml.object]); - - registerCreateEnvButtonFeatures(disposables); - - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - }); - - test('Some random file open in the editor on extension activate', async () => { - const someFile = getSomeFile(); - getOpenTextDocumentsStub.returns([someFile.object]); - - registerCreateEnvButtonFeatures(disposables); - - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - }); - - test('Installable pyproject.toml is opened in the editor', async () => { - getOpenTextDocumentsStub.returns([]); - - let handler: (doc: TextDocument) => void = () => { - /* do nothing */ - }; - onDidOpenTextDocumentStub.callsFake((callback) => { - handler = callback; - return new FakeDisposable(); - }); - - const pyprojectToml = getInstallableToml(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.neverCalledWith('setContext', 'pipInstallableToml', true)); - - handler(pyprojectToml.object); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', true)); - }); - - test('Non Installable pyproject.toml is opened in the editor', async () => { - getOpenTextDocumentsStub.returns([]); - - let handler: (doc: TextDocument) => void = () => { - /* do nothing */ - }; - onDidOpenTextDocumentStub.callsFake((callback) => { - handler = callback; - return new FakeDisposable(); - }); - - const pyprojectToml = getNonInstallableToml(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - executeCommandStub.reset(); - - handler(pyprojectToml.object); - - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - }); - - test('Some random file is opened in the editor', async () => { - getOpenTextDocumentsStub.returns([]); - - let handler: (doc: TextDocument) => void = () => { - /* do nothing */ - }; - onDidOpenTextDocumentStub.callsFake((callback) => { - handler = callback; - return new FakeDisposable(); - }); - - const someFile = getSomeFile(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - executeCommandStub.reset(); - - handler(someFile.object); - - assert.ok(executeCommandStub.neverCalledWith('setContext', 'pipInstallableToml', false)); - }); - - test('Installable pyproject.toml is changed', async () => { - getOpenTextDocumentsStub.returns([]); - - let handler: (d: TextDocumentChangeEvent) => void = () => { - /* do nothing */ - }; - onDidChangeTextDocumentStub.callsFake((callback) => { - handler = callback; - return new FakeDisposable(); - }); - - const pyprojectToml = getInstallableToml(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - - handler({ contentChanges: [], document: pyprojectToml.object, reason: undefined }); - - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', true)); - }); - - test('Non Installable pyproject.toml is changed', async () => { - getOpenTextDocumentsStub.returns([]); - - let handler: (d: TextDocumentChangeEvent) => void = () => { - /* do nothing */ - }; - onDidChangeTextDocumentStub.callsFake((callback) => { - handler = callback; - return new FakeDisposable(); - }); - - const pyprojectToml = getNonInstallableToml(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - executeCommandStub.reset(); - - handler({ contentChanges: [], document: pyprojectToml.object, reason: undefined }); - - assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); - }); - - test('Non Installable pyproject.toml is changed to Installable', async () => { - getOpenTextDocumentsStub.returns([]); - - let openHandler: (doc: TextDocument) => void = () => { - /* do nothing */ - }; - onDidOpenTextDocumentStub.callsFake((callback) => { - openHandler = callback; - return new FakeDisposable(); - }); - - let changeHandler: (d: TextDocumentChangeEvent) => void = () => { - /* do nothing */ - }; - onDidChangeTextDocumentStub.callsFake((callback) => { - changeHandler = callback; - return new FakeDisposable(); - }); - - const nonInatallablePyprojectToml = getNonInstallableToml(); - const installablePyprojectToml = getInstallableToml(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - executeCommandStub.reset(); - - openHandler(nonInatallablePyprojectToml.object); - assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); - executeCommandStub.reset(); - - changeHandler({ contentChanges: [], document: installablePyprojectToml.object, reason: undefined }); - - assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); - }); - - test('Some random file is changed', async () => { - getOpenTextDocumentsStub.returns([]); - - let handler: (d: TextDocumentChangeEvent) => void = () => { - /* do nothing */ - }; - onDidChangeTextDocumentStub.callsFake((callback) => { - handler = callback; - return new FakeDisposable(); - }); - - const someFile = getSomeFile(); - - registerCreateEnvButtonFeatures(disposables); - assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); - executeCommandStub.reset(); - - handler({ contentChanges: [], document: someFile.object, reason: undefined }); - - assert.ok(executeCommandStub.notCalled); - }); }); diff --git a/src/test/pythonEnvironments/creation/installedPackagesDiagnostics.unit.test.ts b/src/test/pythonEnvironments/creation/installedPackagesDiagnostics.unit.test.ts new file mode 100644 index 000000000000..10fe06bba442 --- /dev/null +++ b/src/test/pythonEnvironments/creation/installedPackagesDiagnostics.unit.test.ts @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { assert, use as chaiUse } from 'chai'; +import { Diagnostic, DiagnosticCollection, TextEditor, Range, Uri, TextDocument } from 'vscode'; +import * as cmdApis from '../../../client/common/vscodeApis/commandApis'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import * as languageApis from '../../../client/common/vscodeApis/languageApis'; +import * as windowApis from '../../../client/common/vscodeApis/windowApis'; +import { IDisposableRegistry, IInterpreterPathService } from '../../../client/common/types'; +import * as installUtils from '../../../client/pythonEnvironments/creation/common/installCheckUtils'; +import { + DEPS_NOT_INSTALLED_KEY, + registerInstalledPackagesDiagnosticsProvider, +} from '../../../client/pythonEnvironments/creation/installedPackagesDiagnostic'; + +chaiUse(chaiAsPromised); + +class FakeDisposable { + public dispose() { + // Do nothing + } +} + +const MISSING_PACKAGES: Diagnostic[] = [ + { + range: new Range(8, 34, 8, 44), + message: 'Package `flake8-csv` is not installed in the selected environment.', + source: 'Python-InstalledPackagesChecker', + code: { value: 'not-installed', target: Uri.parse(`https://pypi.org/p/flake8-csv`) }, + severity: 3, + relatedInformation: [], + }, +]; + +function getSomeFile(): typemoq.IMock { + const someFilePath = 'something.py'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'print("Hello World")'); + return someFile; +} + +function getSomeRequirementFile(): typemoq.IMock { + const someFilePath = 'requirements.txt'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.languageId).returns(() => 'pip-requirements'); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'flake8-csv'); + return someFile; +} + +function getPyProjectTomlFile(): typemoq.IMock { + const someFilePath = 'pyproject.toml'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.languageId).returns(() => 'toml'); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile + .setup((p) => p.getText(typemoq.It.isAny())) + .returns( + () => + '[build-system]\nrequires = ["flit_core >=3.2,<4"]\nbuild-backend = "flit_core.buildapi"\n\n[project]\nname = "something"\nversion = "2023.0.0"\nrequires-python = ">=3.7"\ndependencies = ["attrs>=21.3.0", "flake8-csv"]\n ', + ); + return someFile; +} + +function getSomeTomlFile(): typemoq.IMock { + const someFilePath = 'something.toml'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.languageId).returns(() => 'toml'); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile + .setup((p) => p.getText(typemoq.It.isAny())) + .returns( + () => + '[build-system]\nrequires = ["flit_core >=3.2,<4"]\nbuild-backend = "flit_core.buildapi"\n\n[something]\nname = "something"\nversion = "2023.0.0"\nrequires-python = ">=3.7"\ndependencies = ["attrs>=21.3.0", "flake8-csv"]\n ', + ); + return someFile; +} + +suite('Create Env content button settings tests', () => { + let executeCommandStub: sinon.SinonStub; + const disposables: IDisposableRegistry = []; + let getOpenTextDocumentsStub: sinon.SinonStub; + let onDidOpenTextDocumentStub: sinon.SinonStub; + let onDidSaveTextDocumentStub: sinon.SinonStub; + let onDidCloseTextDocumentStub: sinon.SinonStub; + let onDidChangeDiagnosticsStub: sinon.SinonStub; + let onDidChangeActiveTextEditorStub: sinon.SinonStub; + let createDiagnosticCollectionStub: sinon.SinonStub; + let diagnosticCollection: typemoq.IMock; + let getActiveTextEditorStub: sinon.SinonStub; + let textEditor: typemoq.IMock; + let getInstalledPackagesDiagnosticsStub: sinon.SinonStub; + let interpreterPathService: typemoq.IMock; + + setup(() => { + executeCommandStub = sinon.stub(cmdApis, 'executeCommand'); + + getOpenTextDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments'); + getOpenTextDocumentsStub.returns([]); + + onDidOpenTextDocumentStub = sinon.stub(workspaceApis, 'onDidOpenTextDocument'); + onDidSaveTextDocumentStub = sinon.stub(workspaceApis, 'onDidSaveTextDocument'); + onDidCloseTextDocumentStub = sinon.stub(workspaceApis, 'onDidCloseTextDocument'); + onDidOpenTextDocumentStub.returns(new FakeDisposable()); + onDidSaveTextDocumentStub.returns(new FakeDisposable()); + onDidCloseTextDocumentStub.returns(new FakeDisposable()); + + onDidChangeDiagnosticsStub = sinon.stub(languageApis, 'onDidChangeDiagnostics'); + onDidChangeDiagnosticsStub.returns(new FakeDisposable()); + createDiagnosticCollectionStub = sinon.stub(languageApis, 'createDiagnosticCollection'); + diagnosticCollection = typemoq.Mock.ofType(); + diagnosticCollection.setup((d) => d.set(typemoq.It.isAny(), typemoq.It.isAny())).returns(() => undefined); + diagnosticCollection.setup((d) => d.clear()).returns(() => undefined); + diagnosticCollection.setup((d) => d.delete(typemoq.It.isAny())).returns(() => undefined); + diagnosticCollection.setup((d) => d.has(typemoq.It.isAny())).returns(() => false); + createDiagnosticCollectionStub.returns(diagnosticCollection.object); + + onDidChangeActiveTextEditorStub = sinon.stub(windowApis, 'onDidChangeActiveTextEditor'); + onDidChangeActiveTextEditorStub.returns(new FakeDisposable()); + getActiveTextEditorStub = sinon.stub(windowApis, 'getActiveTextEditor'); + textEditor = typemoq.Mock.ofType(); + getActiveTextEditorStub.returns(textEditor.object); + + getInstalledPackagesDiagnosticsStub = sinon.stub(installUtils, 'getInstalledPackagesDiagnostics'); + interpreterPathService = typemoq.Mock.ofType(); + interpreterPathService + .setup((i) => i.onDidChange(typemoq.It.isAny(), undefined, undefined)) + .returns(() => new FakeDisposable()); + }); + + teardown(() => { + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + test('Ensure nothing is run if there are no open documents', () => { + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + assert.ok(executeCommandStub.notCalled); + assert.ok(getInstalledPackagesDiagnosticsStub.notCalled); + }); + + test('Should not run packages check if opened files are not dep files', () => { + const someFile = getSomeFile(); + const someTomlFile = getSomeTomlFile(); + getOpenTextDocumentsStub.returns([someFile.object, someTomlFile.object]); + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + assert.ok(executeCommandStub.notCalled); + assert.ok(getInstalledPackagesDiagnosticsStub.notCalled); + }); + + test('Should run packages check if opened files are dep files', () => { + const reqFile = getSomeRequirementFile(); + const tomlFile = getPyProjectTomlFile(); + getOpenTextDocumentsStub.returns([reqFile.object, tomlFile.object]); + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + assert.ok(getInstalledPackagesDiagnosticsStub.calledTwice); + }); + + [getSomeRequirementFile().object, getPyProjectTomlFile().object].forEach((file) => { + test(`Should run packages check on open of a dep file: ${file.fileName}`, () => { + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + getInstalledPackagesDiagnosticsStub.reset(); + + getInstalledPackagesDiagnosticsStub.returns(Promise.resolve(MISSING_PACKAGES)); + + handler(file); + assert.ok(getInstalledPackagesDiagnosticsStub.calledOnce); + }); + + test(`Should run packages check on save of a dep file: ${file.fileName}`, () => { + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidSaveTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + getInstalledPackagesDiagnosticsStub.reset(); + + getInstalledPackagesDiagnosticsStub.returns(Promise.resolve(MISSING_PACKAGES)); + + handler(file); + assert.ok(getInstalledPackagesDiagnosticsStub.calledOnce); + }); + + test(`Should run packages check on close of a dep file: ${file.fileName}`, () => { + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidCloseTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + + diagnosticCollection.reset(); + diagnosticCollection.setup((d) => d.delete(typemoq.It.isAny())).verifiable(typemoq.Times.once()); + diagnosticCollection + .setup((d) => d.has(typemoq.It.isAny())) + .returns(() => true) + .verifiable(typemoq.Times.once()); + + handler(file); + diagnosticCollection.verifyAll(); + }); + + test(`Should trigger a context update on active editor switch to dep file: ${file.fileName}`, () => { + let handler: () => void = () => { + /* do nothing */ + }; + onDidChangeActiveTextEditorStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + + getActiveTextEditorStub.returns({ document: file }); + diagnosticCollection.setup((d) => d.get(typemoq.It.isAny())).returns(() => MISSING_PACKAGES); + + handler(); + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', DEPS_NOT_INSTALLED_KEY, true)); + }); + + test(`Should trigger a context update to true on diagnostic change to dep file: ${file.fileName}`, () => { + let handler: () => void = () => { + /* do nothing */ + }; + onDidChangeDiagnosticsStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + + getActiveTextEditorStub.returns({ document: file }); + diagnosticCollection.setup((d) => d.get(typemoq.It.isAny())).returns(() => MISSING_PACKAGES); + + handler(); + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', DEPS_NOT_INSTALLED_KEY, true)); + }); + }); + + [getSomeFile().object, getSomeTomlFile().object].forEach((file) => { + test(`Should not run packages check on open of a non dep file: ${file.fileName}`, () => { + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + getInstalledPackagesDiagnosticsStub.reset(); + + getInstalledPackagesDiagnosticsStub.returns(Promise.resolve(MISSING_PACKAGES)); + + handler(file); + assert.ok(getInstalledPackagesDiagnosticsStub.notCalled); + }); + + test(`Should not run packages check on save of a non dep file: ${file.fileName}`, () => { + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidSaveTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + getInstalledPackagesDiagnosticsStub.reset(); + + getInstalledPackagesDiagnosticsStub.returns(Promise.resolve(MISSING_PACKAGES)); + + handler(file); + assert.ok(getInstalledPackagesDiagnosticsStub.notCalled); + }); + + test(`Should trigger a context update on active editor switch to non-dep file: ${file.fileName}`, () => { + let handler: () => void = () => { + /* do nothing */ + }; + onDidChangeActiveTextEditorStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + + getActiveTextEditorStub.returns({ document: file }); + diagnosticCollection.setup((d) => d.get(typemoq.It.isAny())).returns(() => []); + + handler(); + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', DEPS_NOT_INSTALLED_KEY, false)); + }); + + test(`Should trigger a context update to false on diagnostic change to non-dep file: ${file.fileName}`, () => { + let handler: () => void = () => { + /* do nothing */ + }; + onDidChangeDiagnosticsStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + registerInstalledPackagesDiagnosticsProvider(disposables, interpreterPathService.object); + + getActiveTextEditorStub.returns({ document: file }); + diagnosticCollection.setup((d) => d.get(typemoq.It.isAny())).returns(() => []); + + handler(); + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', DEPS_NOT_INSTALLED_KEY, false)); + }); + }); +}); diff --git a/src/test/pythonEnvironments/creation/pyProjectTomlContext.unit.test.ts b/src/test/pythonEnvironments/creation/pyProjectTomlContext.unit.test.ts new file mode 100644 index 000000000000..7106ee64162f --- /dev/null +++ b/src/test/pythonEnvironments/creation/pyProjectTomlContext.unit.test.ts @@ -0,0 +1,266 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { assert, use as chaiUse } from 'chai'; +import { TextDocument } from 'vscode'; +import * as cmdApis from '../../../client/common/vscodeApis/commandApis'; +import * as workspaceApis from '../../../client/common/vscodeApis/workspaceApis'; +import { IDisposableRegistry } from '../../../client/common/types'; +import { registerPyProjectTomlFeatures } from '../../../client/pythonEnvironments/creation/pyProjectTomlContext'; + +chaiUse(chaiAsPromised); + +class FakeDisposable { + public dispose() { + // Do nothing + } +} + +function getInstallableToml(): typemoq.IMock { + const pyprojectTomlPath = 'pyproject.toml'; + const pyprojectToml = typemoq.Mock.ofType(); + pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); + pyprojectToml + .setup((p) => p.getText(typemoq.It.isAny())) + .returns( + () => + '[project]\nname = "spam"\nversion = "2020.0.0"\n[build-system]\nrequires = ["setuptools ~= 58.0", "cython ~= 0.29.0"]\n[project.optional-dependencies]\ntest = ["pytest"]\ndoc = ["sphinx", "furo"]', + ); + return pyprojectToml; +} + +function getNonInstallableToml(): typemoq.IMock { + const pyprojectTomlPath = 'pyproject.toml'; + const pyprojectToml = typemoq.Mock.ofType(); + pyprojectToml.setup((p) => p.fileName).returns(() => pyprojectTomlPath); + pyprojectToml + .setup((p) => p.getText(typemoq.It.isAny())) + .returns(() => '[project]\nname = "spam"\nversion = "2020.0.0"\n'); + return pyprojectToml; +} + +function getSomeFile(): typemoq.IMock { + const someFilePath = 'something.py'; + const someFile = typemoq.Mock.ofType(); + someFile.setup((p) => p.fileName).returns(() => someFilePath); + someFile.setup((p) => p.getText(typemoq.It.isAny())).returns(() => 'print("Hello World")'); + return someFile; +} + +suite('PyProject.toml Create Env Features', () => { + let executeCommandStub: sinon.SinonStub; + const disposables: IDisposableRegistry = []; + let getOpenTextDocumentsStub: sinon.SinonStub; + let onDidOpenTextDocumentStub: sinon.SinonStub; + let onDidSaveTextDocumentStub: sinon.SinonStub; + + setup(() => { + executeCommandStub = sinon.stub(cmdApis, 'executeCommand'); + getOpenTextDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments'); + onDidOpenTextDocumentStub = sinon.stub(workspaceApis, 'onDidOpenTextDocument'); + onDidSaveTextDocumentStub = sinon.stub(workspaceApis, 'onDidSaveTextDocument'); + + onDidOpenTextDocumentStub.returns(new FakeDisposable()); + onDidSaveTextDocumentStub.returns(new FakeDisposable()); + }); + + teardown(() => { + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + test('Installable pyproject.toml is already open in the editor on extension activate', async () => { + const pyprojectToml = getInstallableToml(); + getOpenTextDocumentsStub.returns([pyprojectToml.object]); + + registerPyProjectTomlFeatures(disposables); + + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non installable pyproject.toml is already open in the editor on extension activate', async () => { + const pyprojectToml = getNonInstallableToml(); + getOpenTextDocumentsStub.returns([pyprojectToml.object]); + + registerPyProjectTomlFeatures(disposables); + + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file open in the editor on extension activate', async () => { + const someFile = getSomeFile(); + getOpenTextDocumentsStub.returns([someFile.object]); + + registerPyProjectTomlFeatures(disposables); + + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Installable pyproject.toml is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getInstallableToml(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.neverCalledWith('setContext', 'pipInstallableToml', true)); + + handler(pyprojectToml.object); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non Installable pyproject.toml is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getNonInstallableToml(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + executeCommandStub.reset(); + + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Some random file is opened in the editor', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const someFile = getSomeFile(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + executeCommandStub.reset(); + + handler(someFile.object); + + assert.ok(executeCommandStub.neverCalledWith('setContext', 'pipInstallableToml', false)); + }); + + test('Installable pyproject.toml is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocument) => void = () => { + /* do nothing */ + }; + onDidSaveTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getInstallableToml(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Non Installable pyproject.toml is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocument) => void = () => { + /* do nothing */ + }; + onDidSaveTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const pyprojectToml = getNonInstallableToml(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + executeCommandStub.reset(); + + handler(pyprojectToml.object); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + }); + + test('Non Installable pyproject.toml is changed to Installable', async () => { + getOpenTextDocumentsStub.returns([]); + + let openHandler: (doc: TextDocument) => void = () => { + /* do nothing */ + }; + onDidOpenTextDocumentStub.callsFake((callback) => { + openHandler = callback; + return new FakeDisposable(); + }); + + let changeHandler: (d: TextDocument) => void = () => { + /* do nothing */ + }; + onDidSaveTextDocumentStub.callsFake((callback) => { + changeHandler = callback; + return new FakeDisposable(); + }); + + const nonInatallablePyprojectToml = getNonInstallableToml(); + const installablePyprojectToml = getInstallableToml(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + executeCommandStub.reset(); + + openHandler(nonInatallablePyprojectToml.object); + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', false)); + executeCommandStub.reset(); + + changeHandler(installablePyprojectToml.object); + + assert.ok(executeCommandStub.calledOnceWithExactly('setContext', 'pipInstallableToml', true)); + }); + + test('Some random file is changed', async () => { + getOpenTextDocumentsStub.returns([]); + + let handler: (d: TextDocument) => void = () => { + /* do nothing */ + }; + onDidSaveTextDocumentStub.callsFake((callback) => { + handler = callback; + return new FakeDisposable(); + }); + + const someFile = getSomeFile(); + + registerPyProjectTomlFeatures(disposables); + assert.ok(executeCommandStub.calledWithExactly('setContext', 'pipInstallableToml', false)); + executeCommandStub.reset(); + + handler(someFile.object); + + assert.ok(executeCommandStub.notCalled); + }); +});