From 3daed0f644674a7a7a4510c80d414a28d8c758df Mon Sep 17 00:00:00 2001
From: gnikit <giannis.nikiteas@gmail.com>
Date: Mon, 5 Jun 2023 13:05:24 +0100
Subject: [PATCH 1/3] feat: add ms-python API

---
 src/util/ms-python-api/jupyter/types.ts |  50 ++++
 src/util/ms-python-api/types.ts         | 356 ++++++++++++++++++++++++
 2 files changed, 406 insertions(+)
 create mode 100644 src/util/ms-python-api/jupyter/types.ts
 create mode 100644 src/util/ms-python-api/types.ts

diff --git a/src/util/ms-python-api/jupyter/types.ts b/src/util/ms-python-api/jupyter/types.ts
new file mode 100644
index 00000000..cdaf01cd
--- /dev/null
+++ b/src/util/ms-python-api/jupyter/types.ts
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+'use strict';
+
+import { QuickPickItem } from 'vscode';
+
+interface IJupyterServerUri {
+  baseUrl: string;
+  token: string;
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  authorizationHeader: any; // JSON object for authorization header.
+  expiration?: Date; // Date/time when header expires and should be refreshed.
+  displayName: string;
+}
+
+type JupyterServerUriHandle = string;
+
+export interface IJupyterUriProvider {
+  readonly id: string; // Should be a unique string (like a guid)
+  getQuickPickEntryItems(): QuickPickItem[];
+  handleQuickPick(
+    item: QuickPickItem,
+    backEnabled: boolean
+  ): Promise<JupyterServerUriHandle | 'back' | undefined>;
+  getServerUri(handle: JupyterServerUriHandle): Promise<IJupyterServerUri>;
+}
+
+interface IDataFrameInfo {
+  columns?: { key: string; type: ColumnType }[];
+  indexColumn?: string;
+  rowCount?: number;
+}
+
+export interface IDataViewerDataProvider {
+  dispose(): void;
+  getDataFrameInfo(): Promise<IDataFrameInfo>;
+  getAllRows(): Promise<IRowsResponse>;
+  getRows(start: number, end: number): Promise<IRowsResponse>;
+}
+
+enum ColumnType {
+  String = 'string',
+  Number = 'number',
+  Bool = 'bool',
+}
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type IRowsResponse = any[];
diff --git a/src/util/ms-python-api/types.ts b/src/util/ms-python-api/types.ts
new file mode 100644
index 00000000..1cb426d5
--- /dev/null
+++ b/src/util/ms-python-api/types.ts
@@ -0,0 +1,356 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+import { CancellationToken, Event, Uri, WorkspaceFolder } from 'vscode';
+
+import { IDataViewerDataProvider, IJupyterUriProvider } from './jupyter/types';
+
+/*
+ * Do not introduce any breaking changes to this API.
+ * This is the public API for other extensions to interact with this extension.
+ */
+
+export interface IExtensionApi {
+  /**
+   * Promise indicating whether all parts of the extension have completed loading or not.
+   * @type {Promise<void>}
+   * @memberof IExtensionApi
+   */
+  ready: Promise<void>;
+  jupyter: {
+    registerHooks(): void;
+  };
+  debug: {
+    /**
+     * Generate an array of strings for commands to pass to the Python executable to launch the debugger for remote debugging.
+     * Users can append another array of strings of what they want to execute along with relevant arguments to Python.
+     * E.g `['/Users/..../pythonVSCode/pythonFiles/lib/python/debugpy', '--listen', 'localhost:57039', '--wait-for-client']`
+     * @param {string} host
+     * @param {number} port
+     * @param {boolean} [waitUntilDebuggerAttaches=true]
+     * @returns {Promise<string[]>}
+     */
+    getRemoteLauncherCommand(
+      host: string,
+      port: number,
+      waitUntilDebuggerAttaches: boolean
+    ): Promise<string[]>;
+
+    /**
+     * Gets the path to the debugger package used by the extension.
+     * @returns {Promise<string>}
+     */
+    getDebuggerPackagePath(): Promise<string | undefined>;
+  };
+
+  datascience: {
+    /**
+     * Launches Data Viewer component.
+     * @param {IDataViewerDataProvider} dataProvider Instance that will be used by the Data Viewer component to fetch data.
+     * @param {string} title Data Viewer title
+     */
+    showDataViewer(dataProvider: IDataViewerDataProvider, title: string): Promise<void>;
+    /**
+     * Registers a remote server provider component that's used to pick remote jupyter server URIs
+     * @param serverProvider object called back when picking jupyter server URI
+     */
+    registerRemoteServerProvider(serverProvider: IJupyterUriProvider): void;
+  };
+
+  /**
+   * These APIs provide a way for extensions to work with by python environments available in the user's machine
+   * as found by the Python extension. See
+   * https://github.com/microsoft/vscode-python/wiki/Python-Environment-APIs for usage examples and more.
+   */
+  readonly environments: {
+    /**
+     * Returns the environment configured by user in settings. Note that this can be an invalid environment, use
+     * {@link resolveEnvironment} to get full details.
+     * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root
+     * scenario. If `undefined`, then the API returns what ever is set for the workspace.
+     */
+    getActiveEnvironmentPath(resource?: Resource): EnvironmentPath;
+    /**
+     * Sets the active environment path for the python extension for the resource. Configuration target will always
+     * be the workspace folder.
+     * @param environment : If string, it represents the full path to environment folder or python executable
+     * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself.
+     * @param resource : [optional] File or workspace to scope to a particular workspace folder.
+     */
+    updateActiveEnvironmentPath(
+      environment: string | EnvironmentPath | Environment,
+      resource?: Resource
+    ): Promise<void>;
+    /**
+     * This event is triggered when the active environment setting changes.
+     */
+    readonly onDidChangeActiveEnvironmentPath: Event<ActiveEnvironmentPathChangeEvent>;
+    /**
+     * Carries environments known to the extension at the time of fetching the property. Note this may not
+     * contain all environments in the system as a refresh might be going on.
+     *
+     * Only reports environments in the current workspace.
+     */
+    readonly known: readonly Environment[];
+    /**
+     * This event is triggered when the known environment list changes, like when a environment
+     * is found, existing environment is removed, or some details changed on an environment.
+     */
+    readonly onDidChangeEnvironments: Event<EnvironmentsChangeEvent>;
+    /**
+     * This API will trigger environment discovery, but only if it has not already happened in this VSCode session.
+     * Useful for making sure env list is up-to-date when the caller needs it for the first time.
+     *
+     * To force trigger a refresh regardless of whether a refresh was already triggered, see option
+     * {@link RefreshOptions.forceRefresh}.
+     *
+     * Note that if there is a refresh already going on then this returns the promise for that refresh.
+     * @param options Additional options for refresh.
+     * @param token A cancellation token that indicates a refresh is no longer needed.
+     */
+    refreshEnvironments(options?: RefreshOptions, token?: CancellationToken): Promise<void>;
+    /**
+     * Returns details for the given environment, or `undefined` if the env is invalid.
+     * @param environment : If string, it represents the full path to environment folder or python executable
+     * for the environment. Otherwise it can be {@link Environment} or {@link EnvironmentPath} itself.
+     */
+    resolveEnvironment(
+      environment: Environment | EnvironmentPath | string
+    ): Promise<ResolvedEnvironment | undefined>;
+    /**
+     * Returns the environment variables used by the extension for a resource, which includes the custom
+     * variables configured by user in `.env` files.
+     * @param resource : Uri of a file or workspace folder. This is used to determine the env in a multi-root
+     * scenario. If `undefined`, then the API returns what ever is set for the workspace.
+     */
+    getEnvironmentVariables(resource?: Resource): EnvironmentVariables;
+    /**
+     * This event is fired when the environment variables for a resource change. Note it's currently not
+     * possible to detect if environment variables in the system change, so this only fires if custom
+     * environment variables are updated in `.env` files.
+     */
+    readonly onDidEnvironmentVariablesChange: Event<EnvironmentVariablesChangeEvent>;
+  };
+}
+
+export type RefreshOptions = {
+  /**
+   * When `true`, force trigger a refresh regardless of whether a refresh was already triggered. Note this can be expensive so
+   * it's best to only use it if user manually triggers a refresh.
+   */
+  forceRefresh?: boolean;
+};
+
+/**
+ * Details about the environment. Note the environment folder, type and name never changes over time.
+ */
+export type Environment = EnvironmentPath & {
+  /**
+   * Carries details about python executable.
+   */
+  readonly executable: {
+    /**
+     * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to
+     * the environment.
+     */
+    readonly uri: Uri | undefined;
+    /**
+     * Bitness if known at this moment.
+     */
+    readonly bitness: Bitness | undefined;
+    /**
+     * Value of `sys.prefix` in sys module if known at this moment.
+     */
+    readonly sysPrefix: string | undefined;
+  };
+  /**
+   * Carries details if it is an environment, otherwise `undefined` in case of global interpreters and others.
+   */
+  readonly environment:
+    | {
+        /**
+         * Type of the environment.
+         */
+        readonly type: EnvironmentType;
+        /**
+         * Name to the environment if any.
+         */
+        readonly name: string | undefined;
+        /**
+         * Uri of the environment folder.
+         */
+        readonly folderUri: Uri;
+        /**
+         * Any specific workspace folder this environment is created for.
+         */
+        readonly workspaceFolder: WorkspaceFolder | undefined;
+      }
+    | undefined;
+  /**
+   * Carries Python version information known at this moment, carries `undefined` for envs without python.
+   */
+  readonly version:
+    | (VersionInfo & {
+        /**
+         * Value of `sys.version` in sys module if known at this moment.
+         */
+        readonly sysVersion: string | undefined;
+      })
+    | undefined;
+  /**
+   * Tools/plugins which created the environment or where it came from. First value in array corresponds
+   * to the primary tool which manages the environment, which never changes over time.
+   *
+   * Array is empty if no tool is responsible for creating/managing the environment. Usually the case for
+   * global interpreters.
+   */
+  readonly tools: readonly EnvironmentTools[];
+};
+
+/**
+ * Derived form of {@link Environment} where certain properties can no longer be `undefined`. Meant to represent an
+ * {@link Environment} with complete information.
+ */
+export type ResolvedEnvironment = Environment & {
+  /**
+   * Carries complete details about python executable.
+   */
+  readonly executable: {
+    /**
+     * Uri of the python interpreter/executable. Carries `undefined` in case an executable does not belong to
+     * the environment.
+     */
+    readonly uri: Uri | undefined;
+    /**
+     * Bitness of the environment.
+     */
+    readonly bitness: Bitness;
+    /**
+     * Value of `sys.prefix` in sys module.
+     */
+    readonly sysPrefix: string;
+  };
+  /**
+   * Carries complete Python version information, carries `undefined` for envs without python.
+   */
+  readonly version:
+    | (ResolvedVersionInfo & {
+        /**
+         * Value of `sys.version` in sys module if known at this moment.
+         */
+        readonly sysVersion: string;
+      })
+    | undefined;
+};
+
+export type EnvironmentsChangeEvent = {
+  readonly env: Environment;
+  /**
+   * * "add": New environment is added.
+   * * "remove": Existing environment in the list is removed.
+   * * "update": New information found about existing environment.
+   */
+  readonly type: 'add' | 'remove' | 'update';
+};
+
+export type ActiveEnvironmentPathChangeEvent = EnvironmentPath & {
+  /**
+   * Workspace folder the environment changed for.
+   */
+  readonly resource: WorkspaceFolder | undefined;
+};
+
+/**
+ * Uri of a file inside a workspace or workspace folder itself.
+ */
+export type Resource = Uri | WorkspaceFolder;
+
+export type EnvironmentPath = {
+  /**
+   * The ID of the environment.
+   */
+  readonly id: string;
+  /**
+   * Path to environment folder or path to python executable that uniquely identifies an environment. Environments
+   * lacking a python executable are identified by environment folder paths, whereas other envs can be identified
+   * using python executable path.
+   */
+  readonly path: string;
+};
+
+/**
+ * Tool/plugin where the environment came from. It can be {@link KnownEnvironmentTools} or custom string which
+ * was contributed.
+ */
+export type EnvironmentTools = KnownEnvironmentTools | string;
+/**
+ * Tools or plugins the Python extension currently has built-in support for. Note this list is expected to shrink
+ * once tools have their own separate extensions.
+ */
+export type KnownEnvironmentTools =
+  | 'Conda'
+  | 'Pipenv'
+  | 'Poetry'
+  | 'VirtualEnv'
+  | 'Venv'
+  | 'VirtualEnvWrapper'
+  | 'Pyenv'
+  | 'Unknown';
+
+/**
+ * Type of the environment. It can be {@link KnownEnvironmentTypes} or custom string which was contributed.
+ */
+export type EnvironmentType = KnownEnvironmentTypes | string;
+/**
+ * Environment types the Python extension is aware of. Note this list is expected to shrink once tools have their
+ * own separate extensions, in which case they're expected to provide the type themselves.
+ */
+export type KnownEnvironmentTypes = 'VirtualEnvironment' | 'Conda' | 'Unknown';
+
+/**
+ * Carries bitness for an environment.
+ */
+export type Bitness = '64-bit' | '32-bit' | 'Unknown';
+
+/**
+ * The possible Python release levels.
+ */
+export type PythonReleaseLevel = 'alpha' | 'beta' | 'candidate' | 'final';
+
+/**
+ * Release information for a Python version.
+ */
+export type PythonVersionRelease = {
+  readonly level: PythonReleaseLevel;
+  readonly serial: number;
+};
+
+export type VersionInfo = {
+  readonly major: number | undefined;
+  readonly minor: number | undefined;
+  readonly micro: number | undefined;
+  readonly release: PythonVersionRelease | undefined;
+};
+
+export type ResolvedVersionInfo = {
+  readonly major: number;
+  readonly minor: number;
+  readonly micro: number;
+  readonly release: PythonVersionRelease;
+};
+
+/**
+ * A record containing readonly keys.
+ */
+export type EnvironmentVariables = { readonly [key: string]: string | undefined };
+
+export type EnvironmentVariablesChangeEvent = {
+  /**
+   * Workspace folder the environment variables changed for.
+   */
+  readonly resource: WorkspaceFolder | undefined;
+  /**
+   * Updated value of environment variables.
+   */
+  readonly env: EnvironmentVariables;
+};

From 90f4cf2b2e73ef3f6e87177b1dc6695f4d54d15d Mon Sep 17 00:00:00 2001
From: gnikit <giannis.nikiteas@gmail.com>
Date: Sun, 16 Feb 2025 20:39:56 +0000
Subject: [PATCH 2/3] feat: adds ability to detect Python virtual envs

---
 package-lock.json                       | 109 ++++++++++++++++++++++++
 package.json                            |   1 +
 scripts/get_pip_bin_dir.py              |  21 +++++
 src/format/provider.ts                  |   2 +-
 src/lint/provider.ts                    |   3 +-
 src/lsp/client.ts                       |   2 +-
 src/util/ms-python-api/jupyter/types.ts |   2 -
 src/util/python.ts                      |  82 ++++++++++++++++++
 src/util/shell.ts                       |  73 ++++++++++++++++
 src/util/tools.ts                       |  82 +-----------------
 test/integration/linter.test.ts         |   3 +-
 test/unitTest/shell.test.ts             |  53 ++++++++++++
 test/unitTest/tools.test.ts             |  49 +----------
 webpack.config.js                       |   6 ++
 14 files changed, 352 insertions(+), 136 deletions(-)
 create mode 100644 scripts/get_pip_bin_dir.py
 create mode 100644 src/util/python.ts
 create mode 100644 src/util/shell.ts
 create mode 100644 test/unitTest/shell.test.ts

diff --git a/package-lock.json b/package-lock.json
index 742723b6..0fb3c235 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,6 +28,7 @@
         "@typescript-eslint/parser": "^8.24.0",
         "@vscode/test-electron": "^2.4.1",
         "c8": "^10.1.3",
+        "copy-webpack-plugin": "^12.0.2",
         "eslint": "^9.20.1",
         "eslint-plugin-import": "^2.29.1",
         "eslint-plugin-jsdoc": "^50.2.2",
@@ -731,6 +732,19 @@
         "url": "https://github.com/sindresorhus/is?sponsor=1"
       }
     },
+    "node_modules/@sindresorhus/merge-streams": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
+      "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/@szmarczak/http-timer": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
@@ -2763,6 +2777,31 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/copy-webpack-plugin": {
+      "version": "12.0.2",
+      "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz",
+      "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fast-glob": "^3.3.2",
+        "glob-parent": "^6.0.1",
+        "globby": "^14.0.0",
+        "normalize-path": "^3.0.0",
+        "schema-utils": "^4.2.0",
+        "serialize-javascript": "^6.0.2"
+      },
+      "engines": {
+        "node": ">= 18.12.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.1.0"
+      }
+    },
     "node_modules/core-util-is": {
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -4390,6 +4429,50 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/globby": {
+      "version": "14.1.0",
+      "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
+      "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@sindresorhus/merge-streams": "^2.1.0",
+        "fast-glob": "^3.3.3",
+        "ignore": "^7.0.3",
+        "path-type": "^6.0.0",
+        "slash": "^5.1.0",
+        "unicorn-magic": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globby/node_modules/ignore": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.3.tgz",
+      "integrity": "sha512-bAH5jbK/F3T3Jls4I0SO1hmPR0dKU0a7+SY6n1yzRtG54FLO8d6w/nxLFX2Nb7dBu6cCWXPaAME6cYqFUMmuCA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/globby/node_modules/unicorn-magic": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
+      "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/gopd": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7282,6 +7365,19 @@
         "url": "https://github.com/sponsors/isaacs"
       }
     },
+    "node_modules/path-type": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
+      "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/pend": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
@@ -8298,6 +8394,19 @@
         "simple-concat": "^1.0.0"
       }
     },
+    "node_modules/slash": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+      "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/slashes": {
       "version": "3.0.12",
       "resolved": "https://registry.npmjs.org/slashes/-/slashes-3.0.12.tgz",
diff --git a/package.json b/package.json
index e0bf6d06..2da9fd8e 100644
--- a/package.json
+++ b/package.json
@@ -761,6 +761,7 @@
     "@typescript-eslint/parser": "^8.24.0",
     "@vscode/test-electron": "^2.4.1",
     "c8": "^10.1.3",
+    "copy-webpack-plugin": "^12.0.2",
     "eslint": "^9.20.1",
     "eslint-plugin-import": "^2.29.1",
     "eslint-plugin-jsdoc": "^50.2.2",
diff --git a/scripts/get_pip_bin_dir.py b/scripts/get_pip_bin_dir.py
new file mode 100644
index 00000000..4aec16ae
--- /dev/null
+++ b/scripts/get_pip_bin_dir.py
@@ -0,0 +1,21 @@
+import os
+import sysconfig
+
+
+def get_install_bin_dir() -> str:
+    """Get the path to the pip install directory for scripts (bin, Scripts).
+    Works for virtualenv, conda, and system installs.
+
+    Returns
+    -------
+    str
+        Path to the pip install directory for scripts (bin, Scripts).
+    """
+    if ("VIRTUAL_ENV" in os.environ) or ("CONDA_PREFIX" in os.environ):
+        return sysconfig.get_path("scripts")
+    else:
+        return sysconfig.get_path("scripts", f"{os.name}_user")
+
+
+if __name__ == "__main__":
+    print(get_install_bin_dir())
diff --git a/src/format/provider.ts b/src/format/provider.ts
index e8bae6b6..bcd15cce 100644
--- a/src/format/provider.ts
+++ b/src/format/provider.ts
@@ -6,12 +6,12 @@ import * as vscode from 'vscode';
 import which from 'which';
 
 import { Logger } from '../services/logging';
+import { spawnAsPromise } from '../util/shell';
 import {
   FORMATTERS,
   EXTENSION_ID,
   promptForMissingTool,
   getWholeFileRange,
-  spawnAsPromise,
   pathRelToAbs,
 } from '../util/tools';
 
diff --git a/src/lint/provider.ts b/src/lint/provider.ts
index c3a071d7..ff68fae1 100644
--- a/src/lint/provider.ts
+++ b/src/lint/provider.ts
@@ -21,14 +21,13 @@ import {
 import { Logger } from '../services/logging';
 import { GlobPaths } from '../util/glob-paths';
 import { arraysEqual } from '../util/helper';
+import { spawnAsPromise, shellTask } from '../util/shell';
 import {
   EXTENSION_ID,
   resolveVariables,
   promptForMissingTool,
   isFreeForm,
-  spawnAsPromise,
   isFortran,
-  shellTask,
 } from '../util/tools';
 
 import { GNULinter, GNUModernLinter, IntelLinter, LFortranLinter, NAGLinter } from './compilers';
diff --git a/src/lsp/client.ts b/src/lsp/client.ts
index 5789f2d8..3919769e 100644
--- a/src/lsp/client.ts
+++ b/src/lsp/client.ts
@@ -10,13 +10,13 @@ import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-lan
 
 import { RestartLS } from '../commands/commands';
 import { Logger } from '../services/logging';
+import { pipInstall } from '../util/python';
 import {
   EXTENSION_ID,
   FortranDocumentSelector,
   LS_NAME,
   isFortran,
   getOuterMostWorkspaceFolder,
-  pipInstall,
   resolveVariables,
 } from '../util/tools';
 
diff --git a/src/util/ms-python-api/jupyter/types.ts b/src/util/ms-python-api/jupyter/types.ts
index cdaf01cd..c7fbb6a6 100644
--- a/src/util/ms-python-api/jupyter/types.ts
+++ b/src/util/ms-python-api/jupyter/types.ts
@@ -9,7 +9,6 @@ interface IJupyterServerUri {
   baseUrl: string;
   token: string;
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
   authorizationHeader: any; // JSON object for authorization header.
   expiration?: Date; // Date/time when header expires and should be refreshed.
   displayName: string;
@@ -46,5 +45,4 @@ enum ColumnType {
   Bool = 'bool',
 }
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
 type IRowsResponse = any[];
diff --git a/src/util/python.ts b/src/util/python.ts
new file mode 100644
index 00000000..c60012a9
--- /dev/null
+++ b/src/util/python.ts
@@ -0,0 +1,82 @@
+'use strict';
+
+import * as path from 'path';
+
+import { extensions, Uri } from 'vscode';
+
+import { IExtensionApi, ResolvedEnvironment } from './ms-python-api/types';
+import { shellTask, spawnAsPromise } from './shell';
+
+/**
+ * Get Python path from the workspace or the system
+ *
+ * @param resource file, folder or workspace to search for python
+ * @returns string with path to python
+ */
+export async function getPythonPath(resource?: Uri): Promise<string> {
+  const pythonEnv = await getPythonEnvMS(resource);
+  if (pythonEnv) {
+    return pythonEnv.path;
+  }
+  return process.platform === 'win32' ? 'python' : 'python3';
+}
+
+/**
+ * Get path that pip installs binaries into.
+ * Useful, for when the path is not in the PATH environment variable.
+ *
+ * @param resource file, folder or workspace
+ * @returns string with path to pip
+ */
+export async function getPipBinDir(resource?: Uri): Promise<string> {
+  const py = await getPythonPath(resource);
+  const script = path.join(__dirname, 'scripts', 'get_pip_bin_dir.py');
+  const [stdout, stderr] = await spawnAsPromise(py, [script]);
+  if (stderr) {
+    throw new Error(stderr);
+  }
+  return stdout.trim();
+}
+
+/**
+ * A wrapper around a call to `pip` for installing external tools.
+ * Does not explicitly check if `pip` is installed.
+ *
+ * @param pyPackage name of python package in PyPi
+ */
+export async function pipInstall(pyPackage: string): Promise<string> {
+  const py = await getPythonPath();
+  const args = ['-m', 'pip', 'install', '--user', '--upgrade', pyPackage];
+  return await shellTask(py, args, `pip: ${pyPackage}`);
+}
+
+/**
+ * Get the active Python environment, if any, via the ms-python.python
+ * extension API.
+ *
+ * @param resource file/folder/workspace Uri or undefined
+ * @returns Path to the active Python environment or undefined
+ */
+export async function getPythonEnvMS(
+  resource?: Uri | undefined
+): Promise<ResolvedEnvironment | undefined> {
+  try {
+    const extension = extensions.getExtension('ms-python.python');
+    if (!extension) {
+      return undefined; // extension not installed
+    }
+    if (!extension.isActive) {
+      await extension.activate();
+    }
+    const pythonApi: IExtensionApi = extension.exports as IExtensionApi;
+    const activeEnv: ResolvedEnvironment = await pythonApi.environments.resolveEnvironment(
+      pythonApi.environments.getActiveEnvironmentPath(resource)
+    );
+    if (!activeEnv) {
+      return undefined; // no active environment, unlikely but possible
+    }
+    return activeEnv;
+  } catch (error) {
+    return undefined;
+  }
+}
diff --git a/src/util/shell.ts b/src/util/shell.ts
new file mode 100644
index 00000000..a370c66b
--- /dev/null
+++ b/src/util/shell.ts
@@ -0,0 +1,73 @@
+'use strict';
+
+import * as cp from 'child_process';
+
+import * as vscode from 'vscode';
+
+export async function shellTask(command: string, args: string[], name: string): Promise<string> {
+  const task = new vscode.Task(
+    { type: 'shell' },
+    vscode.TaskScope.Workspace,
+    name,
+    'Modern Fortran',
+    new vscode.ShellExecution(command, args)
+  );
+  // Temporay fix to https://github.com/microsoft/vscode/issues/157756
+  (<vscode.Task>task).definition = { type: 'shell', command: command };
+  const execution = await vscode.tasks.executeTask(task);
+  return await new Promise<string>((resolve, reject) => {
+    const disposable = vscode.tasks.onDidEndTaskProcess(e => {
+      if (e.execution === execution) {
+        disposable.dispose();
+        if (e.exitCode !== 0) {
+          reject(`ERROR: ${e.execution.task.name} failed with code ${e.exitCode}`);
+        }
+        resolve(`${name}: shell task completed successfully.`);
+      }
+    });
+  });
+}
+
+/**
+ * Spawn a command as a `Promise`
+ * @param cmd command to execute
+ * @param args arguments to pass to the command
+ * @param options child_process.spawn options
+ * @param input any input to pass to stdin
+ * @param ignoreExitCode ignore the exit code of the process and `resolve` the promise
+ * @returns Tuple[string, string] `[stdout, stderr]`. By default will `reject` if exit code is non-zero.
+ */
+export async function spawnAsPromise(
+  cmd: string,
+  args: ReadonlyArray<string> | undefined,
+  options?: cp.SpawnOptions | undefined,
+  input?: string | undefined,
+  ignoreExitCode?: boolean
+) {
+  return new Promise<[string, string]>((resolve, reject) => {
+    let stdout = '';
+    let stderr = '';
+    const child = cp.spawn(cmd, args, options);
+    child.stdout.on('data', data => {
+      stdout += data;
+    });
+    child.stderr.on('data', data => {
+      stderr += data;
+    });
+    child.on('close', code => {
+      if (ignoreExitCode || code === 0) {
+        resolve([stdout, stderr]);
+      } else {
+        reject([stdout, stderr]);
+      }
+    });
+    child.on('error', err => {
+      reject(err.toString());
+    });
+
+    if (input) {
+      child.stdin.write(input);
+      child.stdin.end();
+    }
+  });
+}
diff --git a/src/util/tools.ts b/src/util/tools.ts
index 87df97cd..04f126ae 100644
--- a/src/util/tools.ts
+++ b/src/util/tools.ts
@@ -1,11 +1,11 @@
 import * as assert from 'assert';
-import * as cp from 'child_process';
 import * as os from 'os';
 import * as path from 'path';
 
 import * as vscode from 'vscode';
 
 import { isString, isArrayOfString } from './helper';
+import { pipInstall } from './python';
 
 export const LS_NAME = 'fortls';
 export const EXTENSION_ID = 'fortran';
@@ -137,42 +137,6 @@ export async function promptForMissingTool(
   });
 }
 
-/**
- * A wrapper around a call to `pip` for installing external tools.
- * Does not explicitly check if `pip` is installed.
- *
- * @param pyPackage name of python package in PyPi
- */
-export async function pipInstall(pyPackage: string): Promise<string> {
-  const py = 'python3'; // Fetches the top-most python in the Shell
-  const args = ['-m', 'pip', 'install', '--user', '--upgrade', pyPackage];
-  return await shellTask(py, args, `pip: ${pyPackage}`);
-}
-
-export async function shellTask(command: string, args: string[], name: string): Promise<string> {
-  const task = new vscode.Task(
-    { type: 'shell' },
-    vscode.TaskScope.Workspace,
-    name,
-    'Modern Fortran',
-    new vscode.ShellExecution(command, args)
-  );
-  // Temporay fix to https://github.com/microsoft/vscode/issues/157756
-  (<vscode.Task>task).definition = { type: 'shell', command: command };
-  const execution = await vscode.tasks.executeTask(task);
-  return await new Promise<string>((resolve, reject) => {
-    const disposable = vscode.tasks.onDidEndTaskProcess(e => {
-      if (e.execution === execution) {
-        disposable.dispose();
-        if (e.exitCode !== 0) {
-          reject(`ERROR: ${e.execution.task.name} failed with code ${e.exitCode}`);
-        }
-        resolve(`${name}: shell task completed successfully.`);
-      }
-    });
-  });
-}
-
 /**
  * Resolve VSCode internal variables `workspaceFolder`, `env`, `config`, etc.
  *
@@ -286,47 +250,3 @@ export function pathRelToAbs(relPath: string, uri: vscode.Uri): string | undefin
 export function getWholeFileRange(document: vscode.TextDocument): vscode.Range {
   return new vscode.Range(0, 0, document.lineCount, 0);
 }
-
-/**
- * Spawn a command as a `Promise`
- * @param cmd command to execute
- * @param args arguments to pass to the command
- * @param options child_process.spawn options
- * @param input any input to pass to stdin
- * @param ignoreExitCode ignore the exit code of the process and `resolve` the promise
- * @returns Tuple[string, string] `[stdout, stderr]`. By default will `reject` if exit code is non-zero.
- */
-export async function spawnAsPromise(
-  cmd: string,
-  args: ReadonlyArray<string> | undefined,
-  options?: cp.SpawnOptions | undefined,
-  input?: string | undefined,
-  ignoreExitCode?: boolean
-) {
-  return new Promise<[string, string]>((resolve, reject) => {
-    let stdout = '';
-    let stderr = '';
-    const child = cp.spawn(cmd, args, options);
-    child.stdout.on('data', data => {
-      stdout += data;
-    });
-    child.stderr.on('data', data => {
-      stderr += data;
-    });
-    child.on('close', code => {
-      if (ignoreExitCode || code === 0) {
-        resolve([stdout, stderr]);
-      } else {
-        reject([stdout, stderr]);
-      }
-    });
-    child.on('error', err => {
-      reject(err.toString());
-    });
-
-    if (input) {
-      child.stdin.write(input);
-      child.stdin.end();
-    }
-  });
-}
diff --git a/test/integration/linter.test.ts b/test/integration/linter.test.ts
index 2cb2106b..43dfacff 100644
--- a/test/integration/linter.test.ts
+++ b/test/integration/linter.test.ts
@@ -29,7 +29,8 @@ import {
 } from '../../src/lint/compilers';
 import { FortranLintingProvider } from '../../src/lint/provider';
 import { LogLevel, Logger } from '../../src/services/logging';
-import { EXTENSION_ID, pipInstall } from '../../src/util/tools';
+import { pipInstall } from '../../src/util/python';
+import { EXTENSION_ID } from '../../src/util/tools';
 
 suite('Linter VS Code commands', async () => {
   let doc: TextDocument;
diff --git a/test/unitTest/shell.test.ts b/test/unitTest/shell.test.ts
new file mode 100644
index 00000000..bbfa0c91
--- /dev/null
+++ b/test/unitTest/shell.test.ts
@@ -0,0 +1,53 @@
+import * as assert from 'assert';
+import * as path from 'path';
+
+import { shellTask, spawnAsPromise } from '../../src/util/shell';
+
+suite('Tools tests', () => {
+  test('shellTask returns correct output', async () => {
+    const name = 'pip: fortls';
+    const output = await shellTask(
+      'python3',
+      ['-m', 'pip', 'install', '--upgrade', '--force', 'fortls'],
+      name
+    );
+    assert.strictEqual(output, `${name}: shell task completed successfully.`);
+  });
+
+  test('shellTask returns rejected promise', async () => {
+    const name = 'pip: fortls';
+    await assert.rejects(shellTask('python3', ['-m', 'pip', 'install', 'fortls2'], name));
+  });
+
+  test('spawnAsPromise correct stdout, stderr output exit code 0', async () => {
+    const [stdout, stderr] = await spawnAsPromise('node', [
+      path.resolve(__dirname, './exit-code.js'),
+    ]);
+    assert.strictEqual(stdout, 'Hello World!');
+    assert.strictEqual(stderr, 'No errors');
+  });
+
+  test('spawnAsPromise correct stdout, stderr output exit code 1', async () => {
+    try {
+      const [stdout, stderr] = await spawnAsPromise('node', [
+        path.resolve(__dirname, './exit-code-err.js'),
+      ]);
+    } catch (error) {
+      const [stdout, stderr] = error;
+      assert.strictEqual(stdout, 'Hello World!');
+      assert.strictEqual(stderr, 'Errors');
+    }
+  });
+
+  test('spawnAsPromise correct stdout, stderr output exit code 1 with ignoreExitCode', async () => {
+    const [stdout, stderr] = await spawnAsPromise(
+      'node',
+      [path.resolve(__dirname, './exit-code-err.js')],
+      undefined,
+      undefined,
+      true
+    );
+    assert.strictEqual(stdout, 'Hello World!');
+    assert.strictEqual(stderr, 'Errors');
+  });
+});
diff --git a/test/unitTest/tools.test.ts b/test/unitTest/tools.test.ts
index 7c59a2a7..5940791c 100644
--- a/test/unitTest/tools.test.ts
+++ b/test/unitTest/tools.test.ts
@@ -3,56 +3,9 @@ import * as path from 'path';
 
 import { Uri } from 'vscode';
 
-import { shellTask, spawnAsPromise, pathRelToAbs } from '../../src/util/tools';
+import { pathRelToAbs } from '../../src/util/tools';
 
 suite('Tools tests', () => {
-  test('shellTask returns correct output', async () => {
-    const name = 'pip: fortls';
-    const output = await shellTask(
-      'python3',
-      ['-m', 'pip', 'install', '--upgrade', '--force', 'fortls'],
-      name
-    );
-    assert.strictEqual(output, `${name}: shell task completed successfully.`);
-  });
-
-  test('shellTask returns rejected promise', async () => {
-    const name = 'pip: fortls';
-    await assert.rejects(shellTask('python3', ['-m', 'pip', 'install', 'fortls2'], name));
-  });
-
-  test('spawnAsPromise correct stdout, stderr output exit code 0', async () => {
-    const [stdout, stderr] = await spawnAsPromise('node', [
-      path.resolve(__dirname, './exit-code.js'),
-    ]);
-    assert.strictEqual(stdout, 'Hello World!');
-    assert.strictEqual(stderr, 'No errors');
-  });
-
-  test('spawnAsPromise correct stdout, stderr output exit code 1', async () => {
-    try {
-      const [stdout, stderr] = await spawnAsPromise('node', [
-        path.resolve(__dirname, './exit-code-err.js'),
-      ]);
-    } catch (error) {
-      const [stdout, stderr] = error;
-      assert.strictEqual(stdout, 'Hello World!');
-      assert.strictEqual(stderr, 'Errors');
-    }
-  });
-
-  test('spawnAsPromise correct stdout, stderr output exit code 1 with ignoreExitCode', async () => {
-    const [stdout, stderr] = await spawnAsPromise(
-      'node',
-      [path.resolve(__dirname, './exit-code-err.js')],
-      undefined,
-      undefined,
-      true
-    );
-    assert.strictEqual(stdout, 'Hello World!');
-    assert.strictEqual(stderr, 'Errors');
-  });
-
   test('Resolve local paths: undefined', () => {
     const root = Uri.parse('/home/user/project');
     const absPath = pathRelToAbs('./sample.f90', root);
diff --git a/webpack.config.js b/webpack.config.js
index 773a2cbf..2edcdeac 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -4,6 +4,7 @@
 
 const path = require('path');
 const webpack = require('webpack');
+const CopyWebpackPlugin = require('copy-webpack-plugin');
 
 /**@type {import('webpack').Configuration}*/
 const config = {
@@ -25,6 +26,11 @@ const config = {
     // support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
     extensions: ['.ts', '.js'],
   },
+  plugins: [
+    new CopyWebpackPlugin({
+      patterns: [{ from: 'scripts/get_pip_bin_dir.py', to: 'scripts/get_pip_bin_dir.py' }],
+    }),
+  ],
   module: {
     rules: [
       {

From 992904ea78eaf40b23d31bea28a6dd7269ef9ce1 Mon Sep 17 00:00:00 2001
From: gnikit <giannis.nikiteas@gmail.com>
Date: Mon, 6 Nov 2023 22:54:14 +0000
Subject: [PATCH 3/3] refactor: half-baked Python config implementation

---
 package.json                  |   2 +-
 scripts/get_console_script.py |  30 +++++++
 scripts/mod_in_env.py         |   8 ++
 src/lsp/client.ts             |  33 +++++++-
 src/util/python-config.ts     |   8 ++
 src/util/python.ts            | 150 +++++++++++++++++++---------------
 src/util/tools.ts             |   5 +-
 webpack.config.js             |   5 +-
 8 files changed, 167 insertions(+), 74 deletions(-)
 create mode 100644 scripts/get_console_script.py
 create mode 100644 scripts/mod_in_env.py
 create mode 100644 src/util/python-config.ts

diff --git a/package.json b/package.json
index 2da9fd8e..4548876e 100644
--- a/package.json
+++ b/package.json
@@ -394,7 +394,7 @@
         "properties": {
           "fortran.fortls.path": {
             "type": "string",
-            "default": "fortls",
+            "default": "",
             "markdownDescription": "Path to the Fortran language server (`fortls`).",
             "order": 10
           },
diff --git a/scripts/get_console_script.py b/scripts/get_console_script.py
new file mode 100644
index 00000000..6bf19445
--- /dev/null
+++ b/scripts/get_console_script.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+import sys
+import pkg_resources
+
+
+def get_console_script_path(package_name: str, script_name: str) -> str | None:
+    # """
+    # Get the absolute path of a console script from a package.
+
+    # @param package_name: The name of the package.
+    # @param script_name: The name of the console script.
+    # @return The absolute path of the console script.
+    # """
+    try:
+        # Get the distribution object for the package
+        dist = pkg_resources.get_distribution(package_name)
+        # Get the entry point for the console script
+        entry_point = dist.get_entry_info("console_scripts", script_name)
+        # Get the path to the script
+        script_path = entry_point.module_name.split(":")[0]
+        # Return the absolute path of the script
+        return pkg_resources.resource_filename(dist, script_path)
+    except Exception as e:
+        # Handle any exceptions that occur
+        print(f"Error: {e}")
+        return None
+
+
+print(get_console_script_path(sys.argv[1], sys.argv[2]))
diff --git a/scripts/mod_in_env.py b/scripts/mod_in_env.py
new file mode 100644
index 00000000..a5bdb11c
--- /dev/null
+++ b/scripts/mod_in_env.py
@@ -0,0 +1,8 @@
+#! /usr/bin/env python3
+
+import sys
+import pkg_resources
+
+# If not present, fails with a DistributionNotFound exception
+if pkg_resources.get_distribution(str(sys.argv[1])).version:
+    exit(0)
diff --git a/src/lsp/client.ts b/src/lsp/client.ts
index 3919769e..1531d94f 100644
--- a/src/lsp/client.ts
+++ b/src/lsp/client.ts
@@ -10,7 +10,7 @@ import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-lan
 
 import { RestartLS } from '../commands/commands';
 import { Logger } from '../services/logging';
-import { pipInstall } from '../util/python';
+import { Python } from '../util/python';
 import {
   EXTENSION_ID,
   FortranDocumentSelector,
@@ -26,11 +26,16 @@ import {
 export const clients: Map<string, LanguageClient> = new Map();
 
 export class FortlsClient {
+  private readonly python: Python;
+  private readonly config: vscode.WorkspaceConfiguration;
+
   constructor(
     private logger: Logger,
     private context?: vscode.ExtensionContext
   ) {
     this.logger.debug('[lsp.client] Fortran Language Server -- constructor');
+    this.python = new Python();
+    this.config = workspace.getConfiguration(EXTENSION_ID);
 
     // if context is present
     if (context !== undefined) {
@@ -316,6 +321,7 @@ export class FortlsClient {
     const ls = await this.fortlsPath();
 
     // Check for version, if this fails fortls provided is invalid
+    const pipBin: string = await this.python.getPipBinDir();
     const results = spawnSync(ls, ['--version']);
     const msg = `It is highly recommended to use the fortls to enable IDE features like hover, peeking, GoTos and many more. 
       For a full list of features the language server adds see: https://fortls.fortran-lang.org`;
@@ -326,7 +332,7 @@ export class FortlsClient {
           if (opt === 'Install') {
             try {
               this.logger.info(`[lsp.client] Downloading ${LS_NAME}`);
-              const msg = await pipInstall(LS_NAME);
+              const msg = await this.python.pipInstall(LS_NAME);
               window.showInformationMessage(msg);
               this.logger.info(`[lsp.client] ${LS_NAME} installed`);
               resolve(false);
@@ -378,12 +384,21 @@ export class FortlsClient {
     const root = folder ? getOuterMostWorkspaceFolder(folder).uri : vscode.Uri.parse(os.homedir());
 
     const config = workspace.getConfiguration(EXTENSION_ID);
-    let executablePath = resolveVariables(config.get<string>('fortls.path'));
+    // TODO: make the default value undefined, make windows use fortls.exe
+    // get the full path of the Python bin dir, check if file exists
+    // else we are running a script with  a relative path (verify this is the case)
+    let executablePath = config.get<string>('fortls.path');
+    if (!executablePath || this.isFortlsPathDefault(executablePath)) {
+      executablePath = 'fortls' + (os.platform() === 'win32' ? '.exe' : '');
+      executablePath = path.join(await this.python.getPipBinDir(), executablePath);
+    } else {
+      executablePath = resolveVariables(executablePath);
+    }
 
     // The path can be resolved as a relative path if:
     // 1. it does not have the default value `fortls` AND
     // 2. is not an absolute path
-    if (executablePath !== 'fortls' && !path.isAbsolute(executablePath)) {
+    if (!path.isAbsolute(executablePath)) {
       this.logger.debug(`[lsp.client] Assuming relative fortls path is to ${root.fsPath}`);
       executablePath = path.join(root.fsPath, executablePath);
     }
@@ -391,6 +406,16 @@ export class FortlsClient {
     return executablePath;
   }
 
+  private async isFortlsPathDefault(path: string): Promise<boolean> {
+    if (path === 'fortls') {
+      return true;
+    }
+    if (os.platform() === 'win32' && path === 'fortls.exe') {
+      return true;
+    }
+    return false;
+  }
+
   /**
    * Restart the language server
    */
diff --git a/src/util/python-config.ts b/src/util/python-config.ts
new file mode 100644
index 00000000..7935bc24
--- /dev/null
+++ b/src/util/python-config.ts
@@ -0,0 +1,8 @@
+'use strict';
+
+import { Uri } from 'vscode';
+
+import { Python } from './python';
+
+// TODO: implement interface and config
+export class PythonConfiguration {}
diff --git a/src/util/python.ts b/src/util/python.ts
index c60012a9..3222df0b 100644
--- a/src/util/python.ts
+++ b/src/util/python.ts
@@ -1,5 +1,3 @@
-'use strict';
-
 import * as path from 'path';
 
 import { extensions, Uri } from 'vscode';
@@ -7,76 +5,96 @@ import { extensions, Uri } from 'vscode';
 import { IExtensionApi, ResolvedEnvironment } from './ms-python-api/types';
 import { shellTask, spawnAsPromise } from './shell';
 
-/**
- * Get Python path from the workspace or the system
- *
- * @param resource file, folder or workspace to search for python
- * @returns string with path to python
- */
-export async function getPythonPath(resource?: Uri): Promise<string> {
-  const pythonEnv = await getPythonEnvMS(resource);
-  if (pythonEnv) {
-    return pythonEnv.path;
-  }
-  return process.platform === 'win32' ? 'python' : 'python3';
-}
+export class Python {
+  public readonly path: Promise<string>;
+  private usingMSPython = false;
+  private pythonEnvMS: ResolvedEnvironment | undefined;
 
-/**
- * Get path that pip installs binaries into.
- * Useful, for when the path is not in the PATH environment variable.
- *
- * @param resource file, folder or workspace
- * @returns string with path to pip
- */
-export async function getPipBinDir(resource?: Uri): Promise<string> {
-  const py = await getPythonPath(resource);
-  const script = path.join(__dirname, 'scripts', 'get_pip_bin_dir.py');
-  const [stdout, stderr] = await spawnAsPromise(py, [script]);
-  if (stderr) {
-    throw new Error(stderr);
+  constructor(resource?: Uri) {
+    this.path = this.getPythonPath(resource);
   }
-  return stdout.trim();
-}
 
-/**
- * A wrapper around a call to `pip` for installing external tools.
- * Does not explicitly check if `pip` is installed.
- *
- * @param pyPackage name of python package in PyPi
- */
-export async function pipInstall(pyPackage: string): Promise<string> {
-  const py = await getPythonPath();
-  const args = ['-m', 'pip', 'install', '--user', '--upgrade', pyPackage];
-  return await shellTask(py, args, `pip: ${pyPackage}`);
-}
+  /**
+   * Get the path to the active Python interpreter.
+   *
+   * @returns The path to the active Python interpreter.
+   */
+  public async getPythonPath(resource?: Uri): Promise<string> {
+    const pythonEnv = await this.getPythonEnvMS(resource);
+    if (pythonEnv) {
+      this.usingMSPython = true;
+      return pythonEnv.path;
+    }
+    return process.platform === 'win32' ? 'python' : 'python3';
+  }
 
-/**
- * Get the active Python environment, if any, via the ms-python.python
- * extension API.
- *
- * @param resource file/folder/workspace Uri or undefined
- * @returns Path to the active Python environment or undefined
- */
-export async function getPythonEnvMS(
-  resource?: Uri | undefined
-): Promise<ResolvedEnvironment | undefined> {
-  try {
-    const extension = extensions.getExtension('ms-python.python');
-    if (!extension) {
-      return undefined; // extension not installed
+  /**
+   * Get the path to the directory where pip installs binaries.
+   *
+   * @returns The path to the directory where pip installs binaries.
+   */
+  public async getPipBinDir(): Promise<string> {
+    const py = await this.path;
+    const script = path.join(__dirname, 'scripts', 'get_pip_bin_dir.py');
+    const [stdout, stderr] = await spawnAsPromise(py, [script]);
+    if (stderr) {
+      throw new Error(stderr);
     }
-    if (!extension.isActive) {
-      await extension.activate();
+    return stdout.trim();
+  }
+
+  /**
+   * Install a Python package using pip.
+   *
+   * @param packageName The name of the package to install.
+   * @returns The output of the pip command.
+   */
+  public async pipInstall(packageName: string): Promise<string> {
+    const py = await this.path;
+    const args = ['-m', 'pip', 'install', '--user', '--upgrade', packageName];
+    return await shellTask(py, args, `pip: ${packageName}`);
+  }
+
+  /**
+   * Get the active Python environment, if any, via the ms-python.python
+   * extension API.
+   *
+   * @returns The active Python environment, or undefined if there is none.
+   */
+  public async getPythonEnvMS(resource?: Uri): Promise<ResolvedEnvironment | undefined> {
+    try {
+      const extension = extensions.getExtension('ms-python.python');
+      if (!extension) {
+        return undefined; // extension not installed
+      }
+      if (!extension.isActive) {
+        await extension.activate();
+      }
+      const pythonApi: IExtensionApi = extension.exports as IExtensionApi;
+      const activeEnv: ResolvedEnvironment = await pythonApi.environments.resolveEnvironment(
+        pythonApi.environments.getActiveEnvironmentPath(resource)
+      );
+      if (!activeEnv) {
+        return undefined; // no active environment, unlikely but possible
+      }
+      this.pythonEnvMS = activeEnv;
+      return activeEnv;
+    } catch (error) {
+      return undefined;
     }
-    const pythonApi: IExtensionApi = extension.exports as IExtensionApi;
-    const activeEnv: ResolvedEnvironment = await pythonApi.environments.resolveEnvironment(
-      pythonApi.environments.getActiveEnvironmentPath(resource)
-    );
-    if (!activeEnv) {
-      return undefined; // no active environment, unlikely but possible
+  }
+
+  public async isInstalled(packageName: string): Promise<boolean> {
+    const py = await this.path;
+    const script = path.join(__dirname, 'scripts', 'mod_in_env.py');
+    try {
+      const [_, stderr] = await spawnAsPromise(py, [script, packageName]);
+      if (stderr) {
+        return false;
+      }
+      return true;
+    } catch (error) {
+      return false;
     }
-    return activeEnv;
-  } catch (error) {
-    return undefined;
   }
 }
diff --git a/src/util/tools.ts b/src/util/tools.ts
index 04f126ae..09704d7f 100644
--- a/src/util/tools.ts
+++ b/src/util/tools.ts
@@ -5,7 +5,7 @@ import * as path from 'path';
 import * as vscode from 'vscode';
 
 import { isString, isArrayOfString } from './helper';
-import { pipInstall } from './python';
+import { Python } from './python';
 
 export const LS_NAME = 'fortls';
 export const EXTENSION_ID = 'fortran';
@@ -119,7 +119,8 @@ export async function promptForMissingTool(
     if (selected === 'Install') {
       if (toolType === 'Python') {
         try {
-          const inst_msg = await pipInstall(tool);
+          const python = new Python();
+          const inst_msg = await python.pipInstall(tool);
           vscode.window.showInformationMessage(inst_msg);
         } catch (error) {
           vscode.window.showErrorMessage(error);
diff --git a/webpack.config.js b/webpack.config.js
index 2edcdeac..ad2949c5 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -28,7 +28,10 @@ const config = {
   },
   plugins: [
     new CopyWebpackPlugin({
-      patterns: [{ from: 'scripts/get_pip_bin_dir.py', to: 'scripts/get_pip_bin_dir.py' }],
+      patterns: [
+        { from: 'scripts/get_pip_bin_dir.py', to: 'scripts/get_pip_bin_dir.py' },
+        { from: 'scripts/mod_in_env.py', to: 'scripts/mod_in_env.py' },
+      ],
     }),
   ],
   module: {