diff --git a/package.json b/package.json index da9d89b51752c..8b364b5d60e6d 100644 --- a/package.json +++ b/package.json @@ -1731,12 +1731,12 @@ { "group": "Python", "command": "python.createEnvironment-button", - "when": "showCreateEnvButton && resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported && !inDiffEditor && pipDepsNotInstalled" + "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 index 7ca3e51e9c275..f0e1c268d270e 100644 --- a/pythonFiles/installed_check.py +++ b/pythonFiles/installed_check.py @@ -6,14 +6,17 @@ import os import pathlib import sys -from typing import Optional, Sequence +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: @@ -21,9 +24,7 @@ def parse_args(argv: Optional[Sequence[str]] = None): parser = argparse.ArgumentParser( description="Check for installed packages against requirements" ) - parser.add_argument( - "REQUIREMENTS", type=str, help="Path to requirements.[txt, in]", nargs="+" - ) + parser.add_argument("FILEPATH", type=str, help="Path to requirements.[txt, in]") return parser.parse_args(argv) @@ -39,32 +40,88 @@ def parse_requirements(line: str) -> Optional[Requirement]: return None -def main(): - args = parse_args() +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 = [] - for req_file in args.REQUIREMENTS: - req_file = pathlib.Path(req_file) - if req_file.exists(): - lines = req_file.read_text(encoding="utf-8").splitlines() - for n, line in enumerate(lines): - 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, - "package": req.name, - "code": "not-installed", - "severity": 3, - } - ) + 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)) diff --git a/pythonFiles/tests/test_data/missing-deps.data b/pythonFiles/tests/test_data/missing-deps.data new file mode 100644 index 0000000000000..9d267064336f1 --- /dev/null +++ b/pythonFiles/tests/test_data/missing-deps.data @@ -0,0 +1,236 @@ +# +# 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==5.0.4 \ + --hash=sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db \ + --hash=sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248 + # via flake8-csv +flake8-csv==0.2.0 \ + --hash=sha256:246e07207fefbf8f80a59ff7e878f153635f562ebaf20cf796a2b00b1528ea9a \ + --hash=sha256:bf3ac6aecbaebe36a2c7d5d275f310996fcc33b7370cdd81feec04b79af2e07c + # via -r requirements-test.in +importlib-metadata==4.2.0 \ + --hash=sha256:057e92c15bc8d9e8109738a48db0ccb31b4d9d5cfbee5a8670879a30be66304b \ + --hash=sha256:b7e52a1f8dec14a75ea73e0891f3060099ca1d8e6a462a4dff11c3e119ea1b31 + # via flake8 +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 +mccabe==0.7.0 \ + --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via flake8 +pycodestyle==2.9.1 \ + --hash=sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785 \ + --hash=sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b + # via flake8 +pyflakes==2.5.0 \ + --hash=sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2 \ + --hash=sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3 + # via flake8 +rapidfuzz==3.0.0 \ + --hash=sha256:05130d9d33c4770116037de9f131e488825165105588cc7143f77733c5b25a6f \ + --hash=sha256:0b81d15da16e97c288c645eb642d8a08d0ab98b827efb2682cab282a45893efe \ + --hash=sha256:0bf1953a4c32ce6e2f3ed1897d0a8dbbbf19456ef0a8e37bae26e007d9fb5096 \ + --hash=sha256:0f7041c550b69d35675e04dc3f0690d0c26499039e942a0b1604c6547951e6fc \ + --hash=sha256:14e9924108a26f2f58aa8cb90f1a106398fa43e359fa5a96b0f328c7bb7f76da \ + --hash=sha256:1a5eb2d1844f375f34e3c83b1a03094293d894472fdd1a095cf35e4dfa2ecf01 \ + --hash=sha256:1a8c372dc278d1d5ced7cdc99ad8cc1d3de0b245e769a6b327c462b98873e5d4 \ + --hash=sha256:1fe6ea9300f347fd3352c755aa04d71a2786afa008d1af1a35830e6a44e7fd5f \ + --hash=sha256:2132d7724c03dd322035cf1b0c23ca9e7e449ec2b7225040a2ca2fa3f1a3bbfa \ + --hash=sha256:24d77723bb20030a91b096326d14b673d2ff1f0c7bbc64ed519992ed8eb5b869 \ + --hash=sha256:284f216f0500cd977830f107da5c3f96e91356dc7993512efc414dbd55679d51 \ + --hash=sha256:2b50f2d429d81a65910f5ee9b14e172e300a09b8b2ecb91d3e4efc5d2583915c \ + --hash=sha256:2bbf9aad86b70283362dc2db3cacb7dcde0ffe6027f54feb0ccb23cf87b6aa11 \ + --hash=sha256:339b94c536ab9c1b1bac245fb6814df3ba104603d2c1a97f8fb41922357bd772 \ + --hash=sha256:351df02889ac3da9f3f7b10e6812740927cfab9def453079da94f83697b03f2f \ + --hash=sha256:35506d04429440333224c3711dfdb4195d34eff733cb48648d0c89a9b99faf14 \ + --hash=sha256:35e21f0718fd1c1853f8f433f2f84f618f6d4a6d9d96bb7c42e39797be600b58 \ + --hash=sha256:3a5670d5475777450fbc70ed8de8d4e3f7c69230a8b539f45bda358a6f9699f2 \ + --hash=sha256:3ab635a544243e1924508bfc3f294c28bdced6d74388ac25041d3dabcaefab75 \ + --hash=sha256:3ba129c3c8202e8bef0d9964b8798913905ad1dc6293e94d7a02d87cdbef2544 \ + --hash=sha256:3c40a626050e37c2f74e2ba00538578d3c4a6baa171d08ed5091b6a03512ac4a \ + --hash=sha256:3f51d35521f86e767d3e640d0ab42908d01c3e05cf54ac1f0b547f3f602800f1 \ + --hash=sha256:4032713943c32fff97d863e6618162923e3b3c31917d437126d9fcf7e33c83d2 \ + --hash=sha256:41e98b3cebfa3e720186eeab37e6c0565895edf848fd958c34ab94c39e743311 \ + --hash=sha256:481c0389c3b26cd2aa498b6924ca6e9a1d1dd5b15ad5f009d273292949e47e24 \ + --hash=sha256:494b613a3e730e08df1c7c14e45c303a0f5c8a701162bfc8ac9079585837de43 \ + --hash=sha256:4a94fe0a42da816e4a6279ac4c23e4ba6de86a529b61b08d5e8e2633b29c781b \ + --hash=sha256:4c1d895d16f62e9ac88d303eb918d90a390bd712055c849e01c558b7ae0fa908 \ + --hash=sha256:4e1da3dce34d742567da0722b9c8dc2b51554ab5a22fdaf763b60209445a7b37 \ + --hash=sha256:4ef3a6aa07b996c789c8d5ab99ed0563d551d32fa9330fd0f52ba28d20fcb662 \ + --hash=sha256:526df35d07083480e751f9679fd1f3e8a0819e8a13586e3860db5b65549a408a \ + --hash=sha256:54fb70b667b882f9939bc6f581957fcb47fec2e7ad652259835c80e9e30230c9 \ + --hash=sha256:562caa39652c72156574fcf60ce7adf29964a031a57ae977c180947e00425b4a \ + --hash=sha256:5759bb0fd13ee030626e8bbb5b644092a043817fb192335ff4c481402b1edd0e \ + --hash=sha256:59c6c9d3ca7d84c5878a74d142816350a3bdfb51e4d10ac104afd396168481f6 \ + --hash=sha256:5d8664d6f844ea9018b4866e8a8dbf49c87f703668b1b3265de83aa3c9941272 \ + --hash=sha256:5f241ca0bcbfcbf90bb48bb1c8dbc1fddc205bee5520f898b994adda3d3f150a \ + --hash=sha256:5fdcc1ce830bf46fbc098c8b6eb3201a8299476153bae7a5d5e86576f7228d0a \ + --hash=sha256:62aac81ef17bab9f664828b9087d0afe5a94ed48396b0456a2503b68e3d567f2 \ + --hash=sha256:62c760748b1253e08ab5138855e8f8d2c25a7cb5a0bfad74bb66db63c27d8a50 \ + --hash=sha256:658be4cabcc229f52a902f5e87205e1b9c29c66e463a267c8d8f237acde56002 \ + --hash=sha256:69270e0cd850984f562b2239d07cde2213e5c3642cd8d550d5ac9a0fcd0882df \ + --hash=sha256:6bf090b9b4ec4df5f0899bbc4055b8b173b33169186d4de1dd3d9c609bd330a2 \ + --hash=sha256:6dbaa605c0f81208efbf166afb23f73b0f3847a1a966bec828f4167f61d0ca4b \ + --hash=sha256:6dfd138dcc0920b71c1d1bc017413b032286a1f33488613dce9e254c454abaf2 \ + --hash=sha256:6ee35eddeddb5f5750d2a9cc55894926969fa0bac80bbe57211ae6fd0d34b39f \ + --hash=sha256:6ff8835a3ba17f3baf3838f2612e3758d7b1ca09eb16c9a382df3bec5bb9bda3 \ + --hash=sha256:738ae2d59ab254c4f173b40b00a9c1f092697949284c59e0879e6e3beb337a69 \ + --hash=sha256:76da6c8972acd58c31efdd09c4c85263ba3b4394d2c2929be4c171a22293bab3 \ + --hash=sha256:7c81891e570e50d0afe43f722f426b0bd602d3c5315f0f290514511d9520b1e6 \ + --hash=sha256:7eea0ca53da78040a6b7bb041af8051c52efa7facc6f18dce33e679f2decaf62 \ + --hash=sha256:7f8d89b16b4752deeb66dd321548c4cfa59819982d43d2ae7ab5d6e0f15bee94 \ + --hash=sha256:82a4ea0742b9e375d4856714ef59241007765edbce34fd2f7d76c552ed93a7d2 \ + --hash=sha256:832d953b5f1462eba5a0830ea7df11b784f090ba5409fc92bccb856d2539b618 \ + --hash=sha256:87eb7e9fb49265c33bda0417cc74c474a891cae60735fbbd75d79a106483888e \ + --hash=sha256:8883267df996b42494f40d533ef3a3fea247531d137773a649fb851747ae12c8 \ + --hash=sha256:88c9e93508128168708aae3ef98eeb422a88204d81ac4492fcea1e1162a6af74 \ + --hash=sha256:8b5d78052e840191b9c7556eb3bd4fe52435e58bd979c75298b65262368dd1fa \ + --hash=sha256:938cc766d0ce9eca95549593b6ca7ff86a2917b9e68c1989ad95485aed0f49dd \ + --hash=sha256:9a9b7d22e46ada4e6a1f1404c267f3f023b44594929913d855f14bc5fb11b53d \ + --hash=sha256:9c263840bda0f532714ecd66f1f82ed3d3460f45e79e8a907f4df8eaafd93d31 \ + --hash=sha256:a0c22a36b611a2d53fada2cb03b276135d08c2703039078ce985d7cc42734fd7 \ + --hash=sha256:a0e0d8798b7048c9db4e139bafb21792013fb043df07bfaf0d3dc9e1df2be5e6 \ + --hash=sha256:a149c944f3c349f6279a8cc4cbfa3e80cc2baaec9d983359698aa792faa44653 \ + --hash=sha256:abc2f05c1f30b9533cb9b85d73c28d93aa99c7ae2992df04c1704fcaf248b59c \ + --hash=sha256:aeb855b62bc351884a672b8f87875c552492d9199c78f33cc8650c283fd7504d \ + --hash=sha256:b85dfb6f0c353c4b37499529f9831620a7bdc61c375e07f8c38b595f93e906e5 \ + --hash=sha256:bc593306faa6c73e50cb31b81efbb580957272b14c5cf6bcf0233adf8a7e118d \ + --hash=sha256:bf5ddf48f63895f949355a1c4d643e0a531c9317d52901f80d5a6299d967b766 \ + --hash=sha256:c50825de43442c4625a2ca1d948c911d116cf9007ad7f29cd27561c99b16947c \ + --hash=sha256:c94fe53da481d8580e6410f3e7e4ba4e9c5786cad1f289fbb6c9c9585c6d78e1 \ + --hash=sha256:c9ca5f4ae767605cefa5156f5fa8561bee61849f9b2ccfb165d7087b4f9af90c \ + --hash=sha256:cf8b1b29028dc1bc6a5654f22425ee6d3967bbd44bc3a117be0f43b03300f928 \ + --hash=sha256:d128f615da9a198cd9b33be658a0c26fabe06a6d28fa4652953853e8d174c2c6 \ + --hash=sha256:d24054843f4cfbb86df608ec1209e6a29b0d2635230577a94e38a9cfa3880d18 \ + --hash=sha256:d25555297466ab5ded3875913fc0bfa78b89b0a32d79bd65ffbd32ae71f07c2d \ + --hash=sha256:db57085c9dbd0b1005d6ad905c610920c49d0752f522d2f34477b13cba24e1d1 \ + --hash=sha256:dc1a39d1cc8e679c7240b2d1ed8366cf740ab8429cc9b582ebd94a5c6ededbe5 \ + --hash=sha256:dc39af05cdf89be426d96fce579c812948a324b022fb11dfea1e99e180d4f68b \ + --hash=sha256:dd2ad4160d8ad9a2abdad1c765fd29e4d9b6b8ce6a707ee48eb2869e7dff0f89 \ + --hash=sha256:e058ecfd8edb04b221d1b2d005f17be932075a16f75b100b275de1d3d220da5f \ + --hash=sha256:e334225a97824d9f75f5cf8e949e129bc183f0762f4c9b7a127d1809461bdc55 \ + --hash=sha256:e85b4f4aeb994c841478e98a9e05bcb7ed8ead084d93bd2ca0683dc5e93b1c36 \ + --hash=sha256:ea5e25378591a698ae5076c582a0135db2cb43270fb2866737ab4cb6fcc34474 \ + --hash=sha256:ebf96d236a52058c010f354256e8de4274621a7f8b5a15dffa54d9b6a1c7e6e8 \ + --hash=sha256:f00a8b3d0b21884ea972d5430797b1a25b9d2c715b3eaf03366903aac5d8398c \ + --hash=sha256:f213bb5d4cd0b1fddf209bafe2d2896320a737fbded3a567d454e54875e4d9cc \ + --hash=sha256:f5767b0e8220b6f8afcc1fe77529e5678470f9e94a1cfc9e29f5b0721dc1496c \ + --hash=sha256:f80986d3c8d55b848d679084231a35273320f658e64f0d86d725bb360e6cd2c4 \ + --hash=sha256:fa098429af4e17fb5bacb0c39f1f8349891356ba7ca540521515b5708fec4a76 \ + --hash=sha256:fbe4b8305cb427b49d70c182a01c91fd85112e0573193a1f9e4fbcec35ea3eff \ + --hash=sha256:fc764665ba19b923696eae6912a2f0fc52bdd7db6c53be178d1dd70eb72f2f68 + # via levenshtein +typing-extensions==4.5.0 \ + --hash=sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb \ + --hash=sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4 + # via importlib-metadata +zipp==3.15.0 \ + --hash=sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b \ + --hash=sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556 + # via importlib-metadata 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 0000000000000..5c2f1178bbdf9 --- /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 0000000000000..f217a0bdade67 --- /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 0000000000000..729bc9169e6f6 --- /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 0000000000000..8bd466d7cba1c --- /dev/null +++ b/pythonFiles/tests/test_installed_check.py @@ -0,0 +1,99 @@ +# 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": 10, + "character": 0, + "endLine": 10, + "endCharacter": 10, + "package": "flake8-csv", + "code": "not-installed", + "severity": 3, + }, + { + "line": 18, + "character": 0, + "endLine": 18, + "endCharacter": 11, + "package": "levenshtein", + "code": "not-installed", + "severity": 3, + }, + { + "line": 134, + "character": 0, + "endLine": 134, + "endCharacter": 9, + "package": "rapidfuzz", + "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 61e8e9f85f9e5..bdd5e10dfc47b 100644 --- a/requirements.in +++ b/requirements.in @@ -10,5 +10,6 @@ typing-extensions==4.5.0 microvenv # Checker for installed packages -packaging importlib_metadata +packaging +tomli diff --git a/requirements.txt b/requirements.txt index 59e7208ed330f..1419e0528ccc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,6 +16,10 @@ 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 diff --git a/src/client/extensionActivation.ts b/src/client/extensionActivation.ts index 598156b0f4453..8dcea063676a9 100644 --- a/src/client/extensionActivation.ts +++ b/src/client/extensionActivation.ts @@ -51,11 +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 { registerInstalledPackagesChecking } from './pythonEnvironments/creation/installedPackagesDiagnostic'; +import { registerAllCreateEnvironmentFeatures } from './pythonEnvironments/creation/registrations'; export async function activateComponents( // `ext` is passed to any extra activation funcs. @@ -98,9 +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); - registerInstalledPackagesChecking(interpreterPathService, 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 0000000000000..8c9817f83b7a6 --- /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 cd009bc6118a6..4ce7d07ad69de 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 index 46f02f087e009..a46a32ce82766 100644 --- a/src/client/pythonEnvironments/creation/installedPackagesDiagnostic.ts +++ b/src/client/pythonEnvironments/creation/installedPackagesDiagnostic.ts @@ -1,9 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License -import { Diagnostic, DiagnosticCollection, DiagnosticSeverity, l10n, Range, TextDocument, Uri } from 'vscode'; -import { installedCheckScript } from '../../common/process/internal/scripts'; -import { plainExec } from '../../common/process/rawProcessApis'; +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'; @@ -15,91 +13,55 @@ import { onDidSaveTextDocument, } from '../../common/vscodeApis/workspaceApis'; import { traceVerbose } from '../../logging'; +import { getInstalledPackagesDiagnostics, INSTALL_CHECKER_SOURCE } from './common/installCheckUtils'; -interface PackageDiagnostic { - package: string; - line: number; - code: string; - severity: DiagnosticSeverity; -} - -const SOURCE = 'Python-Ext'; -const PIP_DEPS_NOT_INSTALLED_KEY = 'pipDepsNotInstalled'; - -async function getPipRequirementsDiagnostics( - interpreterPathService: IInterpreterPathService, - doc: TextDocument, -): Promise { - const interpreter = interpreterPathService.get(doc.uri); - const result = await plainExec(interpreter, [installedCheckScript(), doc.uri.fsPath]); - traceVerbose('Installed packages check result:\n', result.stdout); - let diagnostics: Diagnostic[] = []; - try { - const raw = JSON.parse(result.stdout) as PackageDiagnostic[]; - diagnostics = raw.map((item) => { - const d = new Diagnostic( - new Range(item.line, 0, item.line, item.package.length), - 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 = SOURCE; - return d; - }); - } catch { - diagnostics = []; - } - return diagnostics; -} +export const DEPS_NOT_INSTALLED_KEY = 'pythonDepsNotInstalled'; async function setContextForActiveEditor(diagnosticCollection: DiagnosticCollection): Promise { const doc = getActiveTextEditor()?.document; - if (doc && doc.languageId === 'pip-requirements') { + 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 pip dependencies not installed: ${doc.uri.fsPath}`); - await executeCommand('setContext', PIP_DEPS_NOT_INSTALLED_KEY, true); + 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 pip dependencies not installed: ${doc?.uri.fsPath}`); - await executeCommand('setContext', PIP_DEPS_NOT_INSTALLED_KEY, false); + traceVerbose(`Clearing context for python dependencies not installed: ${doc?.uri.fsPath}`); + await executeCommand('setContext', DEPS_NOT_INSTALLED_KEY, false); } -export function registerInstalledPackagesChecking( - interpreterPathService: IInterpreterPathService, +export function registerInstalledPackagesDiagnosticsProvider( disposables: IDisposableRegistry, + interpreterPathService: IInterpreterPathService, ): void { - const diagnosticCollection = createDiagnosticCollection(SOURCE); + 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 (e: TextDocument) => { - if (e.languageId === 'pip-requirements') { - const diagnostics = await getPipRequirementsDiagnostics(interpreterPathService, e); - if (diagnostics.length > 0) { - diagnosticCollection.set(e.uri, diagnostics); - } else if (diagnosticCollection.has(e.uri)) { - diagnosticCollection.delete(e.uri); - } + 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 (e: TextDocument) => { - if (e.languageId === 'pip-requirements') { - const diagnostics = await getPipRequirementsDiagnostics(interpreterPathService, e); - if (diagnostics.length > 0) { - diagnosticCollection.set(e.uri, diagnostics); - } else if (diagnosticCollection.has(e.uri)) { - diagnosticCollection.delete(e.uri); - } + 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) => { - if (diagnosticCollection.has(e.uri)) { - diagnosticCollection.delete(e.uri); - } + updateDiagnostics(e.uri, []); }), onDidChangeDiagnostics(async () => { await setContextForActiveEditor(diagnosticCollection); @@ -107,16 +69,20 @@ export function registerInstalledPackagesChecking( 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') { - const diagnostics = await getPipRequirementsDiagnostics(interpreterPathService, doc); - if (diagnostics.length > 0) { - diagnosticCollection.set(doc.uri, diagnostics); - } else if (diagnosticCollection.has(doc.uri)) { - diagnosticCollection.delete(doc.uri); - } + 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 0000000000000..5925b7641f458 --- /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 0000000000000..eeb04036bc1bf --- /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 0000000000000..de8e263fc3fec --- /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 31842420dd59a..eec2d066aadb6 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 0000000000000..10fe06bba442b --- /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 0000000000000..7106ee64162f6 --- /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); + }); +});