diff --git a/extensions/positron-python/.devcontainer/Dockerfile b/extensions/positron-python/.devcontainer/Dockerfile index f5f49445b39..5fbf068de65 100644 --- a/extensions/positron-python/.devcontainer/Dockerfile +++ b/extensions/positron-python/.devcontainer/Dockerfile @@ -1,26 +1,18 @@ -# This image will serve as a starting point for devcontainer.json. -# Get latest image of Fedora as the base image. -FROM docker.io/library/fedora:latest +FROM mcr.microsoft.com/devcontainers/typescript-node:16-bookworm -# Install supported python versions and nodejs. -RUN dnf -y --nodocs install /usr/bin/{python3.7,python3.8,python3.9,python3.10,python3.11,git,conda,clang} && \ - dnf clean all +RUN apt-get install -y wget bzip2 -ENV NVM_VERSION=0.39.3 -ENV NODE_VERSION=16.17.1 -ENV NPM_VERSION=8.19.3 - -# Installation instructions from https://github.com/nvm-sh/nvm . -RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v$NVM_VERSION/install.sh | bash -RUN export NVM_DIR="$HOME/.nvm" && \ - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && \ - [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" && \ - nvm install $NODE_VERSION && \ - npm install -g npm@$NPM_VERSION - -# For clean open source builds. -ENV DISABLE_TRANSLATIONS=true +# Run in silent mode and save downloaded script as anaconda.sh. +# Run with /bin/bash and run in silent mode to /opt/conda. +# Also get rid of installation script after finishing. +RUN wget --quiet https://repo.anaconda.com/archive/Anaconda3-2023.07-1-Linux-x86_64.sh -O ~/anaconda.sh && \ + /bin/bash ~/anaconda.sh -b -p /opt/conda && \ + rm ~/anaconda.sh +ENV PATH="/opt/conda/bin:$PATH" +# Sudo apt update needs to run in order for installation of fish to work . +RUN sudo apt update && \ + sudo apt install fish -y diff --git a/extensions/positron-python/.devcontainer/devcontainer.json b/extensions/positron-python/.devcontainer/devcontainer.json index 6435ba5bbda..fe15f35764e 100644 --- a/extensions/positron-python/.devcontainer/devcontainer.json +++ b/extensions/positron-python/.devcontainer/devcontainer.json @@ -21,7 +21,8 @@ }, // Commands to execute on container creation,start. "postCreateCommand": "bash scripts/postCreateCommand.sh", - // Environment variable placed inside containerEnv following: https://containers.dev/implementors/json_reference/#general-properties + "onCreateCommand": "bash scripts/onCreateCommand.sh", + "containerEnv": { "CI_PYTHON_PATH": "/workspaces/vscode-python/.venv/bin/python" } diff --git a/extensions/positron-python/.github/actions/lint/action.yml b/extensions/positron-python/.github/actions/lint/action.yml index 9478550c107..1efa6aab79a 100644 --- a/extensions/positron-python/.github/actions/lint/action.yml +++ b/extensions/positron-python/.github/actions/lint/action.yml @@ -47,3 +47,10 @@ runs: python -m black . --check working-directory: pythonFiles shell: bash + + - name: Run Ruff + run: | + python -m pip install -U ruff + python -m ruff check . + working-directory: pythonFiles + shell: bash diff --git a/extensions/positron-python/.github/actions/smoke-tests/action.yml b/extensions/positron-python/.github/actions/smoke-tests/action.yml index 9ad6e87cdd2..b2d00205043 100644 --- a/extensions/positron-python/.github/actions/smoke-tests/action.yml +++ b/extensions/positron-python/.github/actions/smoke-tests/action.yml @@ -26,7 +26,6 @@ runs: cache-dependency-path: | build/test-requirements.txt requirements.txt - build/smoke-test-requirements.txt - name: Install dependencies (npm ci) run: npm ci --prefer-offline @@ -43,11 +42,6 @@ runs: python -m pip --disable-pip-version-check install -t ./pythonFiles/lib/python --implementation py --no-deps --upgrade --pre debugpy shell: bash - - name: pip install smoke test requirements - run: | - python -m pip install --upgrade -r build/smoke-test-requirements.txt - shell: bash - # Bits from the VSIX are reused by smokeTest.ts to speed things up. - name: Download VSIX uses: actions/download-artifact@v2 diff --git a/extensions/positron-python/.github/release_plan.md b/extensions/positron-python/.github/release_plan.md index e02d7ee45ab..b4ceef69abe 100644 --- a/extensions/positron-python/.github/release_plan.md +++ b/extensions/positron-python/.github/release_plan.md @@ -1,6 +1,6 @@ All dates should align with VS Code's [iteration](https://github.com/microsoft/vscode/labels/iteration-plan) and [endgame](https://github.com/microsoft/vscode/labels/endgame-plan) plans. -Feature freeze is Monday @ 17:00 America/Vancouver, XXX XX. +Feature freeze is Monday @ 17:00 America/Vancouver, XXX XX. At that point, commits to `main` should only be in response to bugs found during endgame testing until the release candidate is ready. NOTE: the number of this release is in the issue title and can be substituted in wherever you see [YYYY.minor]. @@ -51,6 +51,7 @@ NOTE: this PR should make all CI relating to `main` be passing again (such as th - [ ] Manually add/fix any 3rd-party licenses as appropriate based on what the internal build pipeline detects. - [ ] Open appropriate [documentation issues](https://github.com/microsoft/vscode-docs/issues?q=is%3Aissue+is%3Aopen+label%3Apython). - [ ] Contact the PM team to begin drafting a blog post. +- [ ] Announce to the development team that `main` is open again. # Release (Wednesday, XXX XX) diff --git a/extensions/positron-python/.github/workflows/issue-labels.yml b/extensions/positron-python/.github/workflows/issue-labels.yml index ec2c5eb002f..d54015d94e4 100644 --- a/extensions/positron-python/.github/workflows/issue-labels.yml +++ b/extensions/positron-python/.github/workflows/issue-labels.yml @@ -6,8 +6,8 @@ on: env: # To update the list of labels, see `getLabels.js`. - REPO_LABELS: '["area-api","area-data science","area-debugging","area-diagnostics","area-editor-*","area-environments","area-formatting","area-intellisense","area-internal","area-linting","area-terminal","area-testing","author-verification-requested","bug","community ask","debt","dependencies","documentation","experimenting","feature-request","good first issue","help wanted","important","info-needed","invalid-testplan-item","investigating","iteration-candidate","iteration-plan","iteration-plan-draft","javascript","linux","macos","meta","needs community feedback","needs PR","needs proposal","needs spike","no-changelog","on-testplan","partner ask","regression","release-plan","reports-wanted","skip package*.json","skip tests","tensorboard","testplan-item","triage-needed","verification-found","verification-needed","verification-steps-needed","verified","windows"]' - TRIAGERS: '["karrtikr","karthiknadig","paulacamargo25","eleanorjboyd"]' + REPO_LABELS: '["area-api","area-data science","area-debugging","area-diagnostics","area-editor-*","area-environments","area-formatting","area-intellisense","area-internal","area-linting","area-repl","area-terminal","area-testing","author-verification-requested","bug","community ask","debt","dependencies","documentation","experimenting","feature-request","good first issue","help wanted","important","info-needed","invalid-testplan-item","investigating","iteration-candidate","iteration-plan","iteration-plan-draft","javascript","linux","macos","meta","needs community feedback","needs PR","needs proposal","needs spike","no-changelog","on-testplan","partner ask","regression","release-plan","reports-wanted","skip package*.json","skip tests","tensorboard","testplan-item","triage-needed","verification-found","verification-needed","verification-steps-needed","verified","windows"]' + TRIAGERS: '["karrtikr","karthiknadig","paulacamargo25","eleanorjboyd","anthonykim1"]' permissions: issues: write diff --git a/extensions/positron-python/.github/workflows/pr-file-check.yml b/extensions/positron-python/.github/workflows/pr-file-check.yml index 2d227ea0039..ba019c790e9 100644 --- a/extensions/positron-python/.github/workflows/pr-file-check.yml +++ b/extensions/positron-python/.github/workflows/pr-file-check.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'package-lock.json matches package.json' - uses: brettcannon/check-for-changed-files@v1.1.1 + uses: brettcannon/check-for-changed-files@v1.2.0 with: prereq-pattern: 'package.json' file-pattern: 'package-lock.json' @@ -25,7 +25,7 @@ jobs: failure-message: '${prereq-pattern} was edited but ${file-pattern} was not (the ${skip-label} label can be used to pass this check)' - name: 'package.json matches package-lock.json' - uses: brettcannon/check-for-changed-files@v1.1.1 + uses: brettcannon/check-for-changed-files@v1.2.0 with: prereq-pattern: 'package-lock.json' file-pattern: 'package.json' @@ -33,7 +33,7 @@ jobs: failure-message: '${prereq-pattern} was edited but ${file-pattern} was not (the ${skip-label} label can be used to pass this check)' - name: 'Tests' - uses: brettcannon/check-for-changed-files@v1.1.1 + uses: brettcannon/check-for-changed-files@v1.2.0 with: prereq-pattern: src/**/*.ts file-pattern: | diff --git a/extensions/positron-python/.github/workflows/triage-info-needed.yml b/extensions/positron-python/.github/workflows/triage-info-needed.yml index 1c384d824da..c717d7ec94b 100644 --- a/extensions/positron-python/.github/workflows/triage-info-needed.yml +++ b/extensions/positron-python/.github/workflows/triage-info-needed.yml @@ -5,7 +5,7 @@ on: types: [created] env: - TRIAGERS: '["karrtikr","karthiknadig","paulacamargo25","eleanorjboyd", "brettcannon"]' + TRIAGERS: '["karrtikr","karthiknadig","paulacamargo25","eleanorjboyd", "brettcannon","anthonykim1"]' jobs: add_label: diff --git a/extensions/positron-python/build/smoke-test-requirements.txt b/extensions/positron-python/build/smoke-test-requirements.txt deleted file mode 100644 index 7d5ac3da00d..00000000000 --- a/extensions/positron-python/build/smoke-test-requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -# List of requirements for smoke tests (they will attempt to run a kernel) -jupyter -numpy -matplotlib -pandas -livelossplot \ No newline at end of file diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index 49fad9004d8..75d4fa1ca35 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -23,9 +23,7 @@ "envShellEvent", "testObserver", "quickPickItemTooltip", - "envCollectionWorkspace", - "saveEditor", - "envCollectionOptions" + "saveEditor" ], "author": { "name": "Microsoft Corporation" @@ -46,7 +44,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.79.0-20230526" + "vscode": "^1.82.0-20230830" }, "enableTelemetry": false, "keywords": [ @@ -62,8 +60,7 @@ "Formatters", "Other", "Data Science", - "Machine Learning", - "Notebooks" + "Machine Learning" ], "activationEvents": [ "onStartupFinished", @@ -1150,6 +1147,21 @@ "scope": "machine", "type": "string" }, + "python.missingPackage.severity":{ + "default": "Hint", + "description": "%python.missingPackage.severity.description%", + "enum": [ + "Error", + "Hint", + "Information", + "Warning" + ], + "scope": "resource", + "type": "string", + "tags": [ + "experimental" + ] + }, "python.pipenvPath": { "default": "pipenv", "description": "%python.pipenvPath.description%", @@ -1781,7 +1793,7 @@ "category": "Python", "command": "python.clearWorkspaceInterpreter", "title": "%python.command.python.clearWorkspaceInterpreter.title%", - "when": "!virtualWorkspace && shellExecutionSupported && editorLangId == python" + "when": "!virtualWorkspace && shellExecutionSupported" }, { "category": "Python", @@ -2007,6 +2019,12 @@ "title": "%python.command.python.execInTerminalIcon.title%", "when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported" }, + { + "command": "python.execInDedicatedTerminal", + "group": "navigation@0", + "title": "%python.command.python.execInDedicatedTerminal.title%", + "when": "resourceLangId == python && !isInDiffEditor && !virtualWorkspace && shellExecutionSupported" + }, { "command": "python.debugInTerminal", "group": "navigation@2", @@ -2172,7 +2190,7 @@ "@types/stack-trace": "0.0.29", "@types/tmp": "^0.0.33", "@types/uuid": "^8.3.4", - "@types/vscode": "^1.75.0", + "@types/vscode": "^1.81.0", "@types/which": "^2.0.1", "@types/winreg": "^1.2.30", "@types/xml2js": "0.4.9", diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index cc73cd3c675..bfb0a206133 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -10,6 +10,7 @@ "python.command.python.execInConsole.title": "Run Python File in Console", "python.command.python.debugInTerminal.title": "Debug Python File in Terminal", "python.command.python.execInTerminalIcon.title": "Run Python File in Terminal", + "python.command.python.execInDedicatedTerminal.title": "Run Python File in Dedicated Terminal", "python.command.python.setInterpreter.title": "Select Interpreter", "python.command.python.clearWorkspaceInterpreter.title": "Clear Workspace Interpreter Setting", "python.command.python.viewOutput.title": "Show Output", @@ -204,6 +205,7 @@ "python.linting.pylintPath.deprecationMessage": "This setting will soon be deprecated. Please use the Pylint extension. Learn more here: https://aka.ms/AAlgvkb.", "python.logging.level.description": "The logging level the extension logs at, defaults to 'error'", "python.logging.level.deprecation": "This setting is deprecated. Please use command `Developer: Set Log Level...` to set logging level.", + "python.missingPackage.severity.description": "Set severity of missing packages in requirements.txt or pyproject.toml", "python.pipenvPath.description": "Path to the pipenv executable to use for activation.", "python.poetryPath.description": "Path to the poetry executable.", "python.sortImports.args.description": "Arguments passed in. Each argument is a separate item in the array.", diff --git a/extensions/positron-python/pythonExtensionApi/.eslintrc b/extensions/positron-python/pythonExtensionApi/.eslintrc new file mode 100644 index 00000000000..8828c49002e --- /dev/null +++ b/extensions/positron-python/pythonExtensionApi/.eslintrc @@ -0,0 +1,11 @@ +{ + "overrides": [ + { + "files": ["**/main.d.ts"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "padding-line-between-statements": ["error", { "blankLine": "always", "prev": "export", "next": "*" }] + } + } + ] +} diff --git a/extensions/positron-python/pythonExtensionApi/README.md b/extensions/positron-python/pythonExtensionApi/README.md index 3f313721d17..5208d90cdfa 100644 --- a/extensions/positron-python/pythonExtensionApi/README.md +++ b/extensions/positron-python/pythonExtensionApi/README.md @@ -17,17 +17,22 @@ First we need to define a `package.json` for the extension that wants to use the // Depend on the Python extension facade npm module to get easier API access to the // core extension. "dependencies": { - "@vscode/python-extension": "..." + "@vscode/python-extension": "...", + "@types/vscode": "..." }, } ``` +Update `"@types/vscode"` to [a recent version](https://code.visualstudio.com/updates/) of VS Code, say `"^1.81.0"` for VS Code version `"1.81"`, in case there are any conflicts. + The actual source code to get the active environment to run some script could look like this: ```typescript // Import the API import { PythonExtension } from '@vscode/python-extension'; +... + // Load the Python extension API const pythonApi: PythonExtension = await PythonExtension.api(); diff --git a/extensions/positron-python/pythonExtensionApi/package-lock.json b/extensions/positron-python/pythonExtensionApi/package-lock.json index 9c7c4046870..9b4847457b2 100644 --- a/extensions/positron-python/pythonExtensionApi/package-lock.json +++ b/extensions/positron-python/pythonExtensionApi/package-lock.json @@ -1,15 +1,16 @@ { "name": "@vscode/python-extension", - "version": "1.0.0", + "version": "1.0.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@vscode/python-extension", - "version": "1.0.0", + "version": "1.0.4", "license": "MIT", - "dependencies": { - "@types/vscode": "^1.78.0" + "devDependencies": { + "@types/vscode": "^1.78.0", + "typescript": "5.0.4" }, "engines": { "node": ">=16.17.1", @@ -19,14 +20,35 @@ "node_modules/@types/vscode": { "version": "1.80.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.80.0.tgz", - "integrity": "sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg==" + "integrity": "sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=12.20" + } } }, "dependencies": { "@types/vscode": { "version": "1.80.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.80.0.tgz", - "integrity": "sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg==" + "integrity": "sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg==", + "dev": true + }, + "typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true } } } diff --git a/extensions/positron-python/pythonExtensionApi/package.json b/extensions/positron-python/pythonExtensionApi/package.json index 43b4c19877c..86ac58f42f2 100644 --- a/extensions/positron-python/pythonExtensionApi/package.json +++ b/extensions/positron-python/pythonExtensionApi/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-extension", "description": "An API facade for the Python extension in VS Code", - "version": "1.0.0", + "version": "1.0.4", "author": { "name": "Microsoft Corporation" }, @@ -17,7 +17,7 @@ "vscode": "^1.78.0" }, "license": "MIT", - "homepage": "https://github.com/Microsoft/vscode-python", + "homepage": "https://github.com/microsoft/vscode-python/tree/main/pythonExtensionApi", "repository": { "type": "git", "url": "https://github.com/Microsoft/vscode-python" @@ -25,16 +25,18 @@ "bugs": { "url": "https://github.com/Microsoft/vscode-python/issues" }, - "dependencies": { + "devDependencies": { + "typescript": "5.0.4", "@types/vscode": "^1.78.0" }, "scripts": { "prepublishOnly": "echo \"⛔ Can only publish from a secure pipeline ⛔\" && node ../build/fail", "prepack": "npm run all:publish", - "compile": "node ../node_modules/typescript/lib/tsc.js -b ./tsconfig.json", + "compile": "node ./node_modules/typescript/lib/tsc.js -b ./tsconfig.json", "clean": "node ../node_modules/rimraf/bin.js out", "lint": "node ../node_modules/eslint/bin/eslint.js --ext ts src", "all": "npm run clean && npm run compile", - "all:publish": "git clean -xfd . && npm install && npm run compile" + "formatTypings": "node ../node_modules/eslint/bin/eslint.js --fix ./out/main.d.ts", + "all:publish": "git clean -xfd . && npm install && npm run compile && npm run formatTypings" } } diff --git a/extensions/positron-python/pythonExtensionApi/src/main.ts b/extensions/positron-python/pythonExtensionApi/src/main.ts index b9266a73282..4de554bf5a2 100644 --- a/extensions/positron-python/pythonExtensionApi/src/main.ts +++ b/extensions/positron-python/pythonExtensionApi/src/main.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { CancellationToken, Event, Uri, WorkspaceFolder, QuickPickItem, extensions } from 'vscode'; +import { CancellationToken, Event, Uri, WorkspaceFolder, extensions } from 'vscode'; /* * Do not introduce any breaking changes to this API. @@ -10,21 +10,16 @@ import { CancellationToken, Event, Uri, WorkspaceFolder, QuickPickItem, extensio export interface PythonExtension { /** * Promise indicating whether all parts of the extension have completed loading or not. - * @type {Promise} */ ready: Promise; - 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} + * @param host + * @param port + * @param waitUntilDebuggerAttaches Defaults to `true`. */ getRemoteLauncherCommand(host: string, port: number, waitUntilDebuggerAttaches: boolean): Promise; @@ -35,20 +30,6 @@ export interface PythonExtension { getDebuggerPackagePath(): Promise; }; - 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; - /** - * 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 @@ -125,47 +106,6 @@ export interface PythonExtension { }; } -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; - getServerUri(handle: JupyterServerUriHandle): Promise; -} - -interface IDataFrameInfo { - columns?: { key: string; type: ColumnType }[]; - indexColumn?: string; - rowCount?: number; -} - -export interface IDataViewerDataProvider { - dispose(): void; - getDataFrameInfo(): Promise; - getAllRows(): Promise; - getRows(start: number, end: number): Promise; -} - -enum ColumnType { - String = 'string', - Number = 'number', - Bool = 'bool', -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type IRowsResponse = any[]; - export type RefreshOptions = { /** * When `true`, force trigger a refresh regardless of whether a refresh was already triggered. Note this can be expensive so @@ -392,6 +332,9 @@ export const PVSC_EXTENSION_ID = 'ms-python.python'; // eslint-disable-next-line @typescript-eslint/no-namespace export namespace PythonExtension { + /** + * Returns the API exposed by the Python extension in VS Code. + */ export async function api(): Promise { const extension = extensions.getExtension(PVSC_EXTENSION_ID); if (extension === undefined) { diff --git a/extensions/positron-python/pythonExtensionApi/tsconfig.json b/extensions/positron-python/pythonExtensionApi/tsconfig.json index d90209b3a4b..9ab7617023d 100644 --- a/extensions/positron-python/pythonExtensionApi/tsconfig.json +++ b/extensions/positron-python/pythonExtensionApi/tsconfig.json @@ -25,7 +25,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "resolveJsonModule": true, - "removeComments": true + "declaration": true }, "exclude": [ "node_modules", diff --git a/extensions/positron-python/pythonFiles/installed_check.py b/extensions/positron-python/pythonFiles/installed_check.py index f0e1c268d27..4a43a8bc8b3 100644 --- a/extensions/positron-python/pythonFiles/installed_check.py +++ b/extensions/positron-python/pythonFiles/installed_check.py @@ -15,7 +15,11 @@ from importlib_metadata import metadata from packaging.requirements import Requirement -DEFAULT_SEVERITY = 3 +DEFAULT_SEVERITY = "3" # 'Hint' +try: + SEVERITY = int(os.getenv("VSCODE_MISSING_PGK_SEVERITY", DEFAULT_SEVERITY)) +except ValueError: + SEVERITY = int(DEFAULT_SEVERITY) def parse_args(argv: Optional[Sequence[str]] = None): @@ -36,8 +40,9 @@ def parse_requirements(line: str) -> Optional[Requirement]: return req elif req.marker.evaluate(): return req - except: - return None + except Exception: + pass + return None def process_requirements(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]]]: @@ -51,7 +56,7 @@ def process_requirements(req_file: pathlib.Path) -> List[Dict[str, Union[str, in try: # Check if package is installed metadata(req.name) - except: + except Exception: diagnostics.append( { "line": n, @@ -60,7 +65,7 @@ def process_requirements(req_file: pathlib.Path) -> List[Dict[str, Union[str, in "endCharacter": len(req.name), "package": req.name, "code": "not-installed", - "severity": DEFAULT_SEVERITY, + "severity": SEVERITY, } ) return diagnostics @@ -79,7 +84,7 @@ def process_pyproject(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]] try: raw_text = req_file.read_text(encoding="utf-8") pyproject = tomli.loads(raw_text) - except: + except Exception: return diagnostics lines = raw_text.splitlines() @@ -91,7 +96,7 @@ def process_pyproject(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]] try: # Check if package is installed metadata(req.name) - except: + except Exception: diagnostics.append( { "line": n, @@ -100,7 +105,7 @@ def process_pyproject(req_file: pathlib.Path) -> List[Dict[str, Union[str, int]] "endCharacter": end, "package": req.name, "code": "not-installed", - "severity": DEFAULT_SEVERITY, + "severity": SEVERITY, } ) return diagnostics diff --git a/extensions/positron-python/pythonFiles/jedilsp_requirements/requirements.txt b/extensions/positron-python/pythonFiles/jedilsp_requirements/requirements.txt index 37762c8a643..e7c726540f4 100644 --- a/extensions/positron-python/pythonFiles/jedilsp_requirements/requirements.txt +++ b/extensions/positron-python/pythonFiles/jedilsp_requirements/requirements.txt @@ -17,17 +17,18 @@ cattrs==23.1.2 \ docstring-to-markdown==0.12 \ --hash=sha256:40004224b412bd6f64c0f3b85bb357a41341afd66c4b4896709efa56827fb2bb \ --hash=sha256:7df6311a887dccf9e770f51242ec002b19f0591994c4783be49d24cdc1df3737 - # via - # -r pythonFiles/jedilsp_requirements/requirements.in - # jedi-language-server + # via jedi-language-server exceptiongroup==1.1.2 \ --hash=sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5 \ --hash=sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f # via cattrs -importlib-metadata==6.7.0 \ - --hash=sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4 \ - --hash=sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5 - # via typeguard +importlib-metadata==3.10.1 \ + --hash=sha256:2ec0faae539743ae6aaa84b49a169670a465f7f5d64e6add98388cc29fd1f2f6 \ + --hash=sha256:c9356b657de65c53744046fa8f7358afe0714a1af7d570c00c3835c2d724a7c1 + # via + # attrs + # jedi-language-server + # typeguard jedi==0.18.2 \ --hash=sha256:203c1fd9d969ab8f2119ec0a3342e0b49910045abe6af0a3ae83a5764d54639e \ --hash=sha256:bae794c30d07f6d910d32a7048af09b5a39ed740918da923c6b780790ebac612 @@ -35,7 +36,7 @@ jedi==0.18.2 \ jedi-language-server==0.40.0 \ --hash=sha256:53e590400b5cd2f6e363e77a4d824b1883798994b731cb0b4370d103748d30e2 \ --hash=sha256:bacbae2930b6a8a0f1f284c211672fceec94b4808b0415d1c3352fa4b1ac5ad6 - # via -r pythonFiles/jedilsp_requirements/requirements.in + # via -r pythonFiles\jedilsp_requirements\requirements.in lsprotocol==2023.0.0a2 \ --hash=sha256:80aae7e39171b49025876a524937c10be2eb986f4be700ca22ee7d186b8488aa \ --hash=sha256:c4f2f77712b50d065b17f9b50d2b88c480dc2ce4bbaa56eea8269dbf54bc9701 @@ -54,56 +55,50 @@ parso==0.8.3 \ --hash=sha256:8c07be290bb59f03588915921e29e8a50002acaf2cdc5fa0e0114f91709fafa0 \ --hash=sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75 # via jedi -pydantic==1.10.11 \ - --hash=sha256:008c5e266c8aada206d0627a011504e14268a62091450210eda7c07fabe6963e \ - --hash=sha256:0588788a9a85f3e5e9ebca14211a496409cb3deca5b6971ff37c556d581854e7 \ - --hash=sha256:08a6c32e1c3809fbc49debb96bf833164f3438b3696abf0fbeceb417d123e6eb \ - --hash=sha256:16928fdc9cb273c6af00d9d5045434c39afba5f42325fb990add2c241402d151 \ - --hash=sha256:174899023337b9fc685ac8adaa7b047050616136ccd30e9070627c1aaab53a13 \ - --hash=sha256:192c608ad002a748e4a0bed2ddbcd98f9b56df50a7c24d9a931a8c5dd053bd3d \ - --hash=sha256:1954f8778489a04b245a1e7b8b22a9d3ea8ef49337285693cf6959e4b757535e \ - --hash=sha256:2417de68290434461a266271fc57274a138510dca19982336639484c73a07af6 \ - --hash=sha256:265a60da42f9f27e0b1014eab8acd3e53bd0bad5c5b4884e98a55f8f596b2c19 \ - --hash=sha256:331c031ba1554b974c98679bd0780d89670d6fd6f53f5d70b10bdc9addee1713 \ - --hash=sha256:373c0840f5c2b5b1ccadd9286782852b901055998136287828731868027a724f \ - --hash=sha256:3f34739a89260dfa420aa3cbd069fbcc794b25bbe5c0a214f8fb29e363484b66 \ - --hash=sha256:41e0bb6efe86281623abbeeb0be64eab740c865388ee934cd3e6a358784aca6e \ - --hash=sha256:4400015f15c9b464c9db2d5d951b6a780102cfa5870f2c036d37c23b56f7fc1b \ - --hash=sha256:44e51ba599c3ef227e168424e220cd3e544288c57829520dc90ea9cb190c3248 \ - --hash=sha256:469adf96c8e2c2bbfa655fc7735a2a82f4c543d9fee97bd113a7fb509bf5e622 \ - --hash=sha256:5b02d24f7b2b365fed586ed73582c20f353a4c50e4be9ba2c57ab96f8091ddae \ - --hash=sha256:7522a7666157aa22b812ce14c827574ddccc94f361237ca6ea8bb0d5c38f1629 \ - --hash=sha256:787cf23e5a0cde753f2eabac1b2e73ae3844eb873fd1f5bdbff3048d8dbb7604 \ - --hash=sha256:8268a735a14c308923e8958363e3a3404f6834bb98c11f5ab43251a4e410170c \ - --hash=sha256:8dc77064471780262b6a68fe67e013298d130414d5aaf9b562c33987dbd2cf4f \ - --hash=sha256:a451ccab49971af043ec4e0d207cbc8cbe53dbf148ef9f19599024076fe9c25b \ - --hash=sha256:a6c098d4ab5e2d5b3984d3cb2527e2d6099d3de85630c8934efcfdc348a9760e \ - --hash=sha256:abade85268cc92dff86d6effcd917893130f0ff516f3d637f50dadc22ae93999 \ - --hash=sha256:bc64eab9b19cd794a380179ac0e6752335e9555d214cfcb755820333c0784cb3 \ - --hash=sha256:c3339a46bbe6013ef7bdd2844679bfe500347ac5742cd4019a88312aa58a9847 \ - --hash=sha256:d185819a7a059550ecb85d5134e7d40f2565f3dd94cfd870132c5f91a89cf58c \ - --hash=sha256:d7781f1d13b19700b7949c5a639c764a077cbbdd4322ed505b449d3ca8edcb36 \ - --hash=sha256:e297897eb4bebde985f72a46a7552a7556a3dd11e7f76acda0c1093e3dbcf216 \ - --hash=sha256:e6cbfbd010b14c8a905a7b10f9fe090068d1744d46f9e0c021db28daeb8b6de1 \ - --hash=sha256:e9738b0f2e6c70f44ee0de53f2089d6002b10c33264abee07bdb5c7f03038303 \ - --hash=sha256:e9baf78b31da2dc3d3f346ef18e58ec5f12f5aaa17ac517e2ffd026a92a87588 \ - --hash=sha256:ef55392ec4bb5721f4ded1096241e4b7151ba6d50a50a80a2526c854f42e6a2f \ - --hash=sha256:f66d479cf7eb331372c470614be6511eae96f1f120344c25f3f9bb59fb1b5528 \ - --hash=sha256:fe429898f2c9dd209bd0632a606bddc06f8bce081bbd03d1c775a45886e2c1cb \ - --hash=sha256:ff44c5e89315b15ff1f7fdaf9853770b810936d6b01a7bcecaa227d2f8fe444f - # via - # -r pythonFiles/jedilsp_requirements/requirements.in - # jedi-language-server +pydantic==1.10.12 \ + --hash=sha256:0fe8a415cea8f340e7a9af9c54fc71a649b43e8ca3cc732986116b3cb135d303 \ + --hash=sha256:1289c180abd4bd4555bb927c42ee42abc3aee02b0fb2d1223fb7c6e5bef87dbe \ + --hash=sha256:1eb2085c13bce1612da8537b2d90f549c8cbb05c67e8f22854e201bde5d98a47 \ + --hash=sha256:2031de0967c279df0d8a1c72b4ffc411ecd06bac607a212892757db7462fc494 \ + --hash=sha256:2a7bac939fa326db1ab741c9d7f44c565a1d1e80908b3797f7f81a4f86bc8d33 \ + --hash=sha256:2d5a58feb9a39f481eda4d5ca220aa8b9d4f21a41274760b9bc66bfd72595b86 \ + --hash=sha256:2f9a6fab5f82ada41d56b0602606a5506aab165ca54e52bc4545028382ef1c5d \ + --hash=sha256:2fcfb5296d7877af406ba1547dfde9943b1256d8928732267e2653c26938cd9c \ + --hash=sha256:549a8e3d81df0a85226963611950b12d2d334f214436a19537b2efed61b7639a \ + --hash=sha256:598da88dfa127b666852bef6d0d796573a8cf5009ffd62104094a4fe39599565 \ + --hash=sha256:5d1197e462e0364906cbc19681605cb7c036f2475c899b6f296104ad42b9f5fb \ + --hash=sha256:69328e15cfda2c392da4e713443c7dbffa1505bc9d566e71e55abe14c97ddc62 \ + --hash=sha256:6a9dfa722316f4acf4460afdf5d41d5246a80e249c7ff475c43a3a1e9d75cf62 \ + --hash=sha256:6b30bcb8cbfccfcf02acb8f1a261143fab622831d9c0989707e0e659f77a18e0 \ + --hash=sha256:6c076be61cd0177a8433c0adcb03475baf4ee91edf5a4e550161ad57fc90f523 \ + --hash=sha256:771735dc43cf8383959dc9b90aa281f0b6092321ca98677c5fb6125a6f56d58d \ + --hash=sha256:795e34e6cc065f8f498c89b894a3c6da294a936ee71e644e4bd44de048af1405 \ + --hash=sha256:87afda5539d5140cb8ba9e8b8c8865cb5b1463924d38490d73d3ccfd80896b3f \ + --hash=sha256:8fb2aa3ab3728d950bcc885a2e9eff6c8fc40bc0b7bb434e555c215491bcf48b \ + --hash=sha256:a1fcb59f2f355ec350073af41d927bf83a63b50e640f4dbaa01053a28b7a7718 \ + --hash=sha256:a5e7add47a5b5a40c49b3036d464e3c7802f8ae0d1e66035ea16aa5b7a3923ed \ + --hash=sha256:a73f489aebd0c2121ed974054cb2759af8a9f747de120acd2c3394cf84176ccb \ + --hash=sha256:ab26038b8375581dc832a63c948f261ae0aa21f1d34c1293469f135fa92972a5 \ + --hash=sha256:b0d191db0f92dfcb1dec210ca244fdae5cbe918c6050b342d619c09d31eea0cc \ + --hash=sha256:b749a43aa51e32839c9d71dc67eb1e4221bb04af1033a32e3923d46f9effa942 \ + --hash=sha256:b7ccf02d7eb340b216ec33e53a3a629856afe1c6e0ef91d84a4e6f2fb2ca70fe \ + --hash=sha256:ba5b2e6fe6ca2b7e013398bc7d7b170e21cce322d266ffcd57cca313e54fb246 \ + --hash=sha256:ba5c4a8552bff16c61882db58544116d021d0b31ee7c66958d14cf386a5b5350 \ + --hash=sha256:c79e6a11a07da7374f46970410b41d5e266f7f38f6a17a9c4823db80dadf4303 \ + --hash=sha256:ca48477862372ac3770969b9d75f1bf66131d386dba79506c46d75e6b48c1e09 \ + --hash=sha256:dea7adcc33d5d105896401a1f37d56b47d443a2b2605ff8a969a0ed5543f7e33 \ + --hash=sha256:e0a16d274b588767602b7646fa05af2782576a6cf1022f4ba74cbb4db66f6ca8 \ + --hash=sha256:e4129b528c6baa99a429f97ce733fff478ec955513630e61b49804b6cf9b224a \ + --hash=sha256:e5f805d2d5d0a41633651a73fa4ecdd0b3d7a49de4ec3fadf062fe16501ddbf1 \ + --hash=sha256:ef6c96b2baa2100ec91a4b428f80d8f28a3c9e53568219b6c298c1125572ebc6 \ + --hash=sha256:fdbdd1d630195689f325c9ef1a12900524dceb503b00a987663ff4f58669b93d + # via jedi-language-server pygls==1.0.2 \ --hash=sha256:6d278d29fa6559b0f7a448263c85cb64ec6e9369548b02f1a7944060848b21f9 \ --hash=sha256:888ed63d1f650b4fc64d603d73d37545386ec533c0caac921aed80f80ea946a4 # via # -r pythonFiles/jedilsp_requirements/requirements.in # jedi-language-server -pygments==2.15.1 \ - --hash=sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c \ - --hash=sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1 - # via -r pythonFiles/jedilsp_requirements/requirements.in typeguard==3.0.2 \ --hash=sha256:bbe993854385284ab42fd5bd3bee6f6556577ce8b50696d6cb956d704f286c8e \ --hash=sha256:fee5297fdb28f8e9efcb8142b5ee219e02375509cd77ea9d270b5af826358d5a diff --git a/extensions/positron-python/pythonFiles/normalizeSelection.py b/extensions/positron-python/pythonFiles/normalizeSelection.py index 35bc42d6e6f..0363702717a 100644 --- a/extensions/positron-python/pythonFiles/normalizeSelection.py +++ b/extensions/positron-python/pythonFiles/normalizeSelection.py @@ -118,7 +118,7 @@ def normalize_lines(selection): # Insert a newline between each top-level statement, and append a newline to the selection. source = "\n".join(statements) + "\n" - except: + except Exception: # If there's a problem when parsing statements, # append a blank line to end the block and send it as-is. source = selection + "\n\n" diff --git a/extensions/positron-python/pythonFiles/pyproject.toml b/extensions/positron-python/pythonFiles/pyproject.toml index 36ac4bdbd5a..ae9ea1f3605 100644 --- a/extensions/positron-python/pythonFiles/pyproject.toml +++ b/extensions/positron-python/pythonFiles/pyproject.toml @@ -47,8 +47,32 @@ ignore = [ ] [tool.ruff] -line-length = 100 +line-length = 140 ignore = ["E402"] +exclude = [ + # Ignore testing_tools files same as Pyright way + 'get-pip.py', + 'install_debugpy.py', + 'tensorboard_launcher.py', + 'testlauncher.py', + 'visualstudio_py_testlauncher.py', + 'testing_tools/unittest_discovery.py', + 'testing_tools/adapter/util.py', + 'testing_tools/adapter/pytest/_discovery.py', + 'testing_tools/adapter/pytest/_pytest_item.py', + 'tests/debug_adapter/test_install_debugpy.py', + 'tests/testing_tools/adapter/.data', + 'tests/testing_tools/adapter/test___main__.py', + 'tests/testing_tools/adapter/test_discovery.py', + 'tests/testing_tools/adapter/test_functional.py', + 'tests/testing_tools/adapter/test_report.py', + 'tests/testing_tools/adapter/test_util.py', + 'tests/testing_tools/adapter/pytest/test_cli.py', + 'tests/testing_tools/adapter/pytest/test_discovery.py', + 'pythonFiles/testing_tools/*', + 'pythonFiles/testing_tools/adapter/pytest/__init__.py', + 'pythonFiles/tests/pytestadapter/expected_execution_test_output.py', + 'pythonFiles/tests/unittestadapter/.data/discovery_error/file_one.py', + 'pythonFiles/tests/unittestadapter/test_utils.py', -[tool.ruff.per-file-ignores] -"__init__.py" = ["F401"] +] diff --git a/extensions/positron-python/pythonFiles/shell_exec.py b/extensions/positron-python/pythonFiles/shell_exec.py index c521586ca31..4987399a53e 100644 --- a/extensions/positron-python/pythonFiles/shell_exec.py +++ b/extensions/positron-python/pythonFiles/shell_exec.py @@ -1,9 +1,8 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import os -import sys import subprocess +import sys # This is a simple solution to waiting for completion of commands sent to terminal. # 1. Intercept commands send to a terminal diff --git a/extensions/positron-python/pythonFiles/testing_tools/adapter/pytest/__init__.py b/extensions/positron-python/pythonFiles/testing_tools/adapter/pytest/__init__.py index e894f7bcdb8..89b7c066a45 100644 --- a/extensions/positron-python/pythonFiles/testing_tools/adapter/pytest/__init__.py +++ b/extensions/positron-python/pythonFiles/testing_tools/adapter/pytest/__init__.py @@ -3,5 +3,5 @@ from __future__ import absolute_import -from ._cli import add_subparser as add_cli_subparser -from ._discovery import discover +from ._cli import add_subparser as add_cli_subparser # noqa: F401 +from ._discovery import discover # noqa: F401 diff --git a/extensions/positron-python/pythonFiles/tests/positron/test_plots.py b/extensions/positron-python/pythonFiles/tests/positron/test_plots.py index 2f58297b2c2..f81af0ad165 100644 --- a/extensions/positron-python/pythonFiles/tests/positron/test_plots.py +++ b/extensions/positron-python/pythonFiles/tests/positron/test_plots.py @@ -117,7 +117,7 @@ def test_hook_call(hook: PositronDisplayPublisherHook, images_path: Path) -> Non fig_ref.savefig(str(expected)) # Compare actual versus expected figures - err = compare_images(actual, expected, tol=0) + err = compare_images(str(actual), str(expected), tol=0) assert not err @@ -214,12 +214,13 @@ def test_hook_render(figure_comm: DummyComm, images_path: Path) -> None: fig_ref.set_size_inches(width_in, height_in) # Serialize the reference figure as a base64-encoded image + ip = get_ipython() data_ref, _ = ip.display_formatter.format(fig_ref, include=["image/png"], exclude=[]) # type: ignore expected = images_path / "test-hook-render-expected.png" _save_base64_image(data_ref["image/png"], expected) # Compare the actual vs expected figures - err = compare_images(actual, expected, tol=0) + err = compare_images(str(actual), str(expected), tol=0) assert not err diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/pytest.ini b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/pytest.ini new file mode 100644 index 00000000000..e69de29bb2d diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/test_a.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/test_a.py new file mode 100644 index 00000000000..3ec3dd9626c --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/test_a.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +def test_a_function(): # test_marker--test_a_function + assert True diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/test_b.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/test_b.py new file mode 100644 index 00000000000..0d3148641f8 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/root/tests/test_b.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +def test_b_function(): # test_marker--test_b_function + assert True diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_skiptest_file_level.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_skiptest_file_level.py new file mode 100644 index 00000000000..362c74cbb76 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_skiptest_file_level.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest +from unittest import SkipTest + +# Due to the skip at the file level, no tests will be discovered. +raise SkipTest("Skip all tests in this file, they should not be recognized by pytest.") + + +class SimpleTest(unittest.TestCase): + def testadd1(self): + assert True diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_discovery_test_output.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_discovery_test_output.py index fb8234350fb..2b2c07ab8ea 100644 --- a/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_discovery_test_output.py +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_discovery_test_output.py @@ -1,6 +1,7 @@ import os -from .helpers import TEST_DATA_PATH, find_test_line_number + +from .helpers import TEST_DATA_PATH, find_test_line_number, get_absolute_test_id # This file contains the expected output dictionaries for tests discovery and is used in test_discovery.py. @@ -18,7 +19,7 @@ # This is the expected output for the simple_pytest.py file. # └── simple_pytest.py # └── test_function -simple_test_file_path = os.fspath(TEST_DATA_PATH / "simple_pytest.py") +simple_test_file_path = TEST_DATA_PATH / "simple_pytest.py" simple_discovery_pytest_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -26,20 +27,24 @@ "children": [ { "name": "simple_pytest.py", - "path": simple_test_file_path, + "path": os.fspath(simple_test_file_path), "type_": "file", - "id_": simple_test_file_path, + "id_": os.fspath(simple_test_file_path), "children": [ { "name": "test_function", - "path": simple_test_file_path, + "path": os.fspath(simple_test_file_path), "lineno": find_test_line_number( "test_function", simple_test_file_path, ), "type_": "test", - "id_": "simple_pytest.py::test_function", - "runID": "simple_pytest.py::test_function", + "id_": get_absolute_test_id( + "simple_pytest.py::test_function", simple_test_file_path + ), + "runID": get_absolute_test_id( + "simple_pytest.py::test_function", simple_test_file_path + ), } ], } @@ -52,7 +57,7 @@ # ├── TestExample # │ └── test_true_unittest # └── test_true_pytest -unit_pytest_same_file_path = os.fspath(TEST_DATA_PATH / "unittest_pytest_same_file.py") +unit_pytest_same_file_path = TEST_DATA_PATH / "unittest_pytest_same_file.py" unit_pytest_same_file_discovery_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -60,39 +65,51 @@ "children": [ { "name": "unittest_pytest_same_file.py", - "path": unit_pytest_same_file_path, + "path": os.fspath(unit_pytest_same_file_path), "type_": "file", - "id_": unit_pytest_same_file_path, + "id_": os.fspath(unit_pytest_same_file_path), "children": [ { "name": "TestExample", - "path": unit_pytest_same_file_path, + "path": os.fspath(unit_pytest_same_file_path), "type_": "class", "children": [ { "name": "test_true_unittest", - "path": unit_pytest_same_file_path, + "path": os.fspath(unit_pytest_same_file_path), "lineno": find_test_line_number( "test_true_unittest", - unit_pytest_same_file_path, + os.fspath(unit_pytest_same_file_path), ), "type_": "test", - "id_": "unittest_pytest_same_file.py::TestExample::test_true_unittest", - "runID": "unittest_pytest_same_file.py::TestExample::test_true_unittest", + "id_": get_absolute_test_id( + "unittest_pytest_same_file.py::TestExample::test_true_unittest", + unit_pytest_same_file_path, + ), + "runID": get_absolute_test_id( + "unittest_pytest_same_file.py::TestExample::test_true_unittest", + unit_pytest_same_file_path, + ), } ], "id_": "unittest_pytest_same_file.py::TestExample", }, { "name": "test_true_pytest", - "path": unit_pytest_same_file_path, + "path": os.fspath(unit_pytest_same_file_path), "lineno": find_test_line_number( "test_true_pytest", unit_pytest_same_file_path, ), "type_": "test", - "id_": "unittest_pytest_same_file.py::test_true_pytest", - "runID": "unittest_pytest_same_file.py::test_true_pytest", + "id_": get_absolute_test_id( + "unittest_pytest_same_file.py::test_true_pytest", + unit_pytest_same_file_path, + ), + "runID": get_absolute_test_id( + "unittest_pytest_same_file.py::test_true_pytest", + unit_pytest_same_file_path, + ), }, ], } @@ -100,6 +117,16 @@ "id_": TEST_DATA_PATH_STR, } +# This is the expected output for the unittest_skip_file_level test. +# └── unittest_skiptest_file_level.py +unittest_skip_file_level_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [], + "id_": TEST_DATA_PATH_STR, +} + # This is the expected output for the unittest_folder tests # └── unittest_folder # ├── test_add.py @@ -114,9 +141,9 @@ # └── test_subtract_positive_numbers # │ └── TestDuplicateFunction # │ └── test_dup_s -unittest_folder_path = os.fspath(TEST_DATA_PATH / "unittest_folder") -test_add_path = os.fspath(TEST_DATA_PATH / "unittest_folder" / "test_add.py") -test_subtract_path = os.fspath(TEST_DATA_PATH / "unittest_folder" / "test_subtract.py") +unittest_folder_path = TEST_DATA_PATH / "unittest_folder" +test_add_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py" +test_subtract_path = TEST_DATA_PATH / "unittest_folder" / "test_subtract.py" unittest_folder_discovery_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -124,61 +151,79 @@ "children": [ { "name": "unittest_folder", - "path": unittest_folder_path, + "path": os.fspath(unittest_folder_path), "type_": "folder", - "id_": unittest_folder_path, + "id_": os.fspath(unittest_folder_path), "children": [ { "name": "test_add.py", - "path": test_add_path, + "path": os.fspath(test_add_path), "type_": "file", - "id_": test_add_path, + "id_": os.fspath(test_add_path), "children": [ { "name": "TestAddFunction", - "path": test_add_path, + "path": os.fspath(test_add_path), "type_": "class", "children": [ { "name": "test_add_negative_numbers", - "path": test_add_path, + "path": os.fspath(test_add_path), "lineno": find_test_line_number( "test_add_negative_numbers", - test_add_path, + os.fspath(test_add_path), ), "type_": "test", - "id_": "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", - "runID": "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", + "id_": get_absolute_test_id( + "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", + test_add_path, + ), + "runID": get_absolute_test_id( + "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", + test_add_path, + ), }, { "name": "test_add_positive_numbers", - "path": test_add_path, + "path": os.fspath(test_add_path), "lineno": find_test_line_number( "test_add_positive_numbers", - test_add_path, + os.fspath(test_add_path), ), "type_": "test", - "id_": "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", - "runID": "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + "id_": get_absolute_test_id( + "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + test_add_path, + ), + "runID": get_absolute_test_id( + "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + test_add_path, + ), }, ], "id_": "unittest_folder/test_add.py::TestAddFunction", }, { "name": "TestDuplicateFunction", - "path": test_add_path, + "path": os.fspath(test_add_path), "type_": "class", "children": [ { "name": "test_dup_a", - "path": test_add_path, + "path": os.fspath(test_add_path), "lineno": find_test_line_number( "test_dup_a", - test_add_path, + os.fspath(test_add_path), ), "type_": "test", - "id_": "unittest_folder/test_add.py::TestDuplicateFunction::test_dup_a", - "runID": "unittest_folder/test_add.py::TestDuplicateFunction::test_dup_a", + "id_": get_absolute_test_id( + "unittest_folder/test_add.py::TestDuplicateFunction::test_dup_a", + test_add_path, + ), + "runID": get_absolute_test_id( + "unittest_folder/test_add.py::TestDuplicateFunction::test_dup_a", + test_add_path, + ), }, ], "id_": "unittest_folder/test_add.py::TestDuplicateFunction", @@ -187,55 +232,73 @@ }, { "name": "test_subtract.py", - "path": test_subtract_path, + "path": os.fspath(test_subtract_path), "type_": "file", - "id_": test_subtract_path, + "id_": os.fspath(test_subtract_path), "children": [ { "name": "TestSubtractFunction", - "path": test_subtract_path, + "path": os.fspath(test_subtract_path), "type_": "class", "children": [ { "name": "test_subtract_negative_numbers", - "path": test_subtract_path, + "path": os.fspath(test_subtract_path), "lineno": find_test_line_number( "test_subtract_negative_numbers", - test_subtract_path, + os.fspath(test_subtract_path), ), "type_": "test", - "id_": "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", - "runID": "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + "id_": get_absolute_test_id( + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + test_subtract_path, + ), }, { "name": "test_subtract_positive_numbers", - "path": test_subtract_path, + "path": os.fspath(test_subtract_path), "lineno": find_test_line_number( "test_subtract_positive_numbers", - test_subtract_path, + os.fspath(test_subtract_path), ), "type_": "test", - "id_": "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", - "runID": "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + "id_": get_absolute_test_id( + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + test_subtract_path, + ), }, ], "id_": "unittest_folder/test_subtract.py::TestSubtractFunction", }, { "name": "TestDuplicateFunction", - "path": test_subtract_path, + "path": os.fspath(test_subtract_path), "type_": "class", "children": [ { "name": "test_dup_s", - "path": test_subtract_path, + "path": os.fspath(test_subtract_path), "lineno": find_test_line_number( "test_dup_s", - test_subtract_path, + os.fspath(test_subtract_path), ), "type_": "test", - "id_": "unittest_folder/test_subtract.py::TestDuplicateFunction::test_dup_s", - "runID": "unittest_folder/test_subtract.py::TestDuplicateFunction::test_dup_s", + "id_": get_absolute_test_id( + "unittest_folder/test_subtract.py::TestDuplicateFunction::test_dup_s", + test_subtract_path, + ), + "runID": get_absolute_test_id( + "unittest_folder/test_subtract.py::TestDuplicateFunction::test_dup_s", + test_subtract_path, + ), }, ], "id_": "unittest_folder/test_subtract.py::TestDuplicateFunction", @@ -258,20 +321,23 @@ # └── test_bottom_folder.py # └── test_bottom_function_t # └── test_bottom_function_f -dual_level_nested_folder_path = os.fspath(TEST_DATA_PATH / "dual_level_nested_folder") -test_top_folder_path = os.fspath( +dual_level_nested_folder_path = TEST_DATA_PATH / "dual_level_nested_folder" +test_top_folder_path = ( TEST_DATA_PATH / "dual_level_nested_folder" / "test_top_folder.py" ) -test_nested_folder_one_path = os.fspath( + +test_nested_folder_one_path = ( TEST_DATA_PATH / "dual_level_nested_folder" / "nested_folder_one" ) -test_bottom_folder_path = os.fspath( + +test_bottom_folder_path = ( TEST_DATA_PATH / "dual_level_nested_folder" / "nested_folder_one" / "test_bottom_folder.py" ) + dual_level_nested_folder_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -279,73 +345,97 @@ "children": [ { "name": "dual_level_nested_folder", - "path": dual_level_nested_folder_path, + "path": os.fspath(dual_level_nested_folder_path), "type_": "folder", - "id_": dual_level_nested_folder_path, + "id_": os.fspath(dual_level_nested_folder_path), "children": [ { "name": "test_top_folder.py", - "path": test_top_folder_path, + "path": os.fspath(test_top_folder_path), "type_": "file", - "id_": test_top_folder_path, + "id_": os.fspath(test_top_folder_path), "children": [ { "name": "test_top_function_t", - "path": test_top_folder_path, + "path": os.fspath(test_top_folder_path), "lineno": find_test_line_number( "test_top_function_t", test_top_folder_path, ), "type_": "test", - "id_": "dual_level_nested_folder/test_top_folder.py::test_top_function_t", - "runID": "dual_level_nested_folder/test_top_folder.py::test_top_function_t", + "id_": get_absolute_test_id( + "dual_level_nested_folder/test_top_folder.py::test_top_function_t", + test_top_folder_path, + ), + "runID": get_absolute_test_id( + "dual_level_nested_folder/test_top_folder.py::test_top_function_t", + test_top_folder_path, + ), }, { "name": "test_top_function_f", - "path": test_top_folder_path, + "path": os.fspath(test_top_folder_path), "lineno": find_test_line_number( "test_top_function_f", test_top_folder_path, ), "type_": "test", - "id_": "dual_level_nested_folder/test_top_folder.py::test_top_function_f", - "runID": "dual_level_nested_folder/test_top_folder.py::test_top_function_f", + "id_": get_absolute_test_id( + "dual_level_nested_folder/test_top_folder.py::test_top_function_f", + test_top_folder_path, + ), + "runID": get_absolute_test_id( + "dual_level_nested_folder/test_top_folder.py::test_top_function_f", + test_top_folder_path, + ), }, ], }, { "name": "nested_folder_one", - "path": test_nested_folder_one_path, + "path": os.fspath(test_nested_folder_one_path), "type_": "folder", - "id_": test_nested_folder_one_path, + "id_": os.fspath(test_nested_folder_one_path), "children": [ { "name": "test_bottom_folder.py", - "path": test_bottom_folder_path, + "path": os.fspath(test_bottom_folder_path), "type_": "file", - "id_": test_bottom_folder_path, + "id_": os.fspath(test_bottom_folder_path), "children": [ { "name": "test_bottom_function_t", - "path": test_bottom_folder_path, + "path": os.fspath(test_bottom_folder_path), "lineno": find_test_line_number( "test_bottom_function_t", test_bottom_folder_path, ), "type_": "test", - "id_": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", - "runID": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + "id_": get_absolute_test_id( + "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + test_bottom_folder_path, + ), + "runID": get_absolute_test_id( + "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + test_bottom_folder_path, + ), }, { "name": "test_bottom_function_f", - "path": test_bottom_folder_path, + "path": os.fspath(test_bottom_folder_path), "lineno": find_test_line_number( "test_bottom_function_f", test_bottom_folder_path, ), "type_": "test", - "id_": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", - "runID": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + "id_": get_absolute_test_id( + "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + test_bottom_folder_path, + ), + "runID": get_absolute_test_id( + "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + test_bottom_folder_path, + ), }, ], } @@ -364,12 +454,10 @@ # └── test_nest.py # └── test_function -folder_a_path = os.fspath(TEST_DATA_PATH / "folder_a") -folder_b_path = os.fspath(TEST_DATA_PATH / "folder_a" / "folder_b") -folder_a_nested_path = os.fspath(TEST_DATA_PATH / "folder_a" / "folder_b" / "folder_a") -test_nest_path = os.fspath( - TEST_DATA_PATH / "folder_a" / "folder_b" / "folder_a" / "test_nest.py" -) +folder_a_path = TEST_DATA_PATH / "folder_a" +folder_b_path = TEST_DATA_PATH / "folder_a" / "folder_b" +folder_a_nested_path = TEST_DATA_PATH / "folder_a" / "folder_b" / "folder_a" +test_nest_path = TEST_DATA_PATH / "folder_a" / "folder_b" / "folder_a" / "test_nest.py" double_nested_folder_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -377,38 +465,44 @@ "children": [ { "name": "folder_a", - "path": folder_a_path, + "path": os.fspath(folder_a_path), "type_": "folder", - "id_": folder_a_path, + "id_": os.fspath(folder_a_path), "children": [ { "name": "folder_b", - "path": folder_b_path, + "path": os.fspath(folder_b_path), "type_": "folder", - "id_": folder_b_path, + "id_": os.fspath(folder_b_path), "children": [ { "name": "folder_a", - "path": folder_a_nested_path, + "path": os.fspath(folder_a_nested_path), "type_": "folder", - "id_": folder_a_nested_path, + "id_": os.fspath(folder_a_nested_path), "children": [ { "name": "test_nest.py", - "path": test_nest_path, + "path": os.fspath(test_nest_path), "type_": "file", - "id_": test_nest_path, + "id_": os.fspath(test_nest_path), "children": [ { "name": "test_function", - "path": test_nest_path, + "path": os.fspath(test_nest_path), "lineno": find_test_line_number( "test_function", test_nest_path, ), "type_": "test", - "id_": "folder_a/folder_b/folder_a/test_nest.py::test_function", - "runID": "folder_a/folder_b/folder_a/test_nest.py::test_function", + "id_": get_absolute_test_id( + "folder_a/folder_b/folder_a/test_nest.py::test_function", + test_nest_path, + ), + "runID": get_absolute_test_id( + "folder_a/folder_b/folder_a/test_nest.py::test_function", + test_nest_path, + ), } ], } @@ -428,7 +522,7 @@ # └── [3+5-8] # └── [2+4-6] # └── [6+9-16] -parameterize_tests_path = os.fspath(TEST_DATA_PATH / "parametrize_tests.py") +parameterize_tests_path = TEST_DATA_PATH / "parametrize_tests.py" parametrize_tests_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -436,77 +530,107 @@ "children": [ { "name": "parametrize_tests.py", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "type_": "file", - "id_": parameterize_tests_path, + "id_": os.fspath(parameterize_tests_path), "children": [ { "name": "test_adding", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "type_": "function", "id_": "parametrize_tests.py::test_adding", "children": [ { "name": "[3+5-8]", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "lineno": find_test_line_number( "test_adding[3+5-8]", parameterize_tests_path, ), "type_": "test", - "id_": "parametrize_tests.py::test_adding[3+5-8]", - "runID": "parametrize_tests.py::test_adding[3+5-8]", + "id_": get_absolute_test_id( + "parametrize_tests.py::test_adding[3+5-8]", + parameterize_tests_path, + ), + "runID": get_absolute_test_id( + "parametrize_tests.py::test_adding[3+5-8]", + parameterize_tests_path, + ), }, { "name": "[2+4-6]", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "lineno": find_test_line_number( "test_adding[2+4-6]", parameterize_tests_path, ), "type_": "test", - "id_": "parametrize_tests.py::test_adding[2+4-6]", - "runID": "parametrize_tests.py::test_adding[2+4-6]", + "id_": get_absolute_test_id( + "parametrize_tests.py::test_adding[2+4-6]", + parameterize_tests_path, + ), + "runID": get_absolute_test_id( + "parametrize_tests.py::test_adding[2+4-6]", + parameterize_tests_path, + ), }, { "name": "[6+9-16]", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "lineno": find_test_line_number( "test_adding[6+9-16]", parameterize_tests_path, ), "type_": "test", - "id_": "parametrize_tests.py::test_adding[6+9-16]", - "runID": "parametrize_tests.py::test_adding[6+9-16]", + "id_": get_absolute_test_id( + "parametrize_tests.py::test_adding[6+9-16]", + parameterize_tests_path, + ), + "runID": get_absolute_test_id( + "parametrize_tests.py::test_adding[6+9-16]", + parameterize_tests_path, + ), }, ], }, { "name": "test_under_ten", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "type_": "function", "children": [ { "name": "[1]", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "lineno": find_test_line_number( "test_under_ten[1]", parameterize_tests_path, ), "type_": "test", - "id_": "parametrize_tests.py::test_under_ten[1]", - "runID": "parametrize_tests.py::test_under_ten[1]", + "id_": get_absolute_test_id( + "parametrize_tests.py::test_under_ten[1]", + parameterize_tests_path, + ), + "runID": get_absolute_test_id( + "parametrize_tests.py::test_under_ten[1]", + parameterize_tests_path, + ), }, { "name": "[2]", - "path": parameterize_tests_path, + "path": os.fspath(parameterize_tests_path), "lineno": find_test_line_number( "test_under_ten[2]", parameterize_tests_path, ), "type_": "test", - "id_": "parametrize_tests.py::test_under_ten[2]", - "runID": "parametrize_tests.py::test_under_ten[2]", + "id_": get_absolute_test_id( + "parametrize_tests.py::test_under_ten[2]", + parameterize_tests_path, + ), + "runID": get_absolute_test_id( + "parametrize_tests.py::test_under_ten[2]", + parameterize_tests_path, + ), }, ], "id_": "parametrize_tests.py::test_under_ten", @@ -519,7 +643,7 @@ # This is the expected output for the text_docstring.txt tests. # └── text_docstring.txt -text_docstring_path = os.fspath(TEST_DATA_PATH / "text_docstring.txt") +text_docstring_path = TEST_DATA_PATH / "text_docstring.txt" doctest_pytest_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -527,20 +651,24 @@ "children": [ { "name": "text_docstring.txt", - "path": text_docstring_path, + "path": os.fspath(text_docstring_path), "type_": "file", - "id_": text_docstring_path, + "id_": os.fspath(text_docstring_path), "children": [ { "name": "text_docstring.txt", - "path": text_docstring_path, + "path": os.fspath(text_docstring_path), "lineno": find_test_line_number( "text_docstring.txt", - text_docstring_path, + os.fspath(text_docstring_path), ), "type_": "test", - "id_": "text_docstring.txt::text_docstring.txt", - "runID": "text_docstring.txt::text_docstring.txt", + "id_": get_absolute_test_id( + "text_docstring.txt::text_docstring.txt", text_docstring_path + ), + "runID": get_absolute_test_id( + "text_docstring.txt::text_docstring.txt", text_docstring_path + ), } ], } @@ -560,8 +688,8 @@ # └── [1] # └── [2] # └── [3] -param1_path = os.fspath(TEST_DATA_PATH / "param_same_name" / "test_param1.py") -param2_path = os.fspath(TEST_DATA_PATH / "param_same_name" / "test_param2.py") +param1_path = TEST_DATA_PATH / "param_same_name" / "test_param1.py" +param2_path = TEST_DATA_PATH / "param_same_name" / "test_param2.py" param_same_name_expected_output = { "name": ".data", "path": TEST_DATA_PATH_STR, @@ -575,38 +703,56 @@ "children": [ { "name": "test_param1.py", - "path": param1_path, + "path": os.fspath(param1_path), "type_": "file", - "id_": param1_path, + "id_": os.fspath(param1_path), "children": [ { "name": "test_odd_even", - "path": param1_path, + "path": os.fspath(param1_path), "type_": "function", "children": [ { "name": "[a]", - "path": param1_path, + "path": os.fspath(param1_path), "lineno": "6", "type_": "test", - "id_": "param_same_name/test_param1.py::test_odd_even[a]", - "runID": "param_same_name/test_param1.py::test_odd_even[a]", + "id_": get_absolute_test_id( + "param_same_name/test_param1.py::test_odd_even[a]", + param1_path, + ), + "runID": get_absolute_test_id( + "param_same_name/test_param1.py::test_odd_even[a]", + param1_path, + ), }, { "name": "[b]", - "path": param1_path, + "path": os.fspath(param1_path), "lineno": "6", "type_": "test", - "id_": "param_same_name/test_param1.py::test_odd_even[b]", - "runID": "param_same_name/test_param1.py::test_odd_even[b]", + "id_": get_absolute_test_id( + "param_same_name/test_param1.py::test_odd_even[b]", + param1_path, + ), + "runID": get_absolute_test_id( + "param_same_name/test_param1.py::test_odd_even[b]", + param1_path, + ), }, { "name": "[c]", - "path": param1_path, + "path": os.fspath(param1_path), "lineno": "6", "type_": "test", - "id_": "param_same_name/test_param1.py::test_odd_even[c]", - "runID": "param_same_name/test_param1.py::test_odd_even[c]", + "id_": get_absolute_test_id( + "param_same_name/test_param1.py::test_odd_even[c]", + param1_path, + ), + "runID": get_absolute_test_id( + "param_same_name/test_param1.py::test_odd_even[c]", + param1_path, + ), }, ], "id_": "param_same_name/test_param1.py::test_odd_even", @@ -615,38 +761,56 @@ }, { "name": "test_param2.py", - "path": param2_path, + "path": os.fspath(param2_path), "type_": "file", - "id_": param2_path, + "id_": os.fspath(param2_path), "children": [ { "name": "test_odd_even", - "path": param2_path, + "path": os.fspath(param2_path), "type_": "function", "children": [ { "name": "[1]", - "path": param2_path, + "path": os.fspath(param2_path), "lineno": "6", "type_": "test", - "id_": "param_same_name/test_param2.py::test_odd_even[1]", - "runID": "param_same_name/test_param2.py::test_odd_even[1]", + "id_": get_absolute_test_id( + "param_same_name/test_param2.py::test_odd_even[1]", + param2_path, + ), + "runID": get_absolute_test_id( + "param_same_name/test_param2.py::test_odd_even[1]", + param2_path, + ), }, { "name": "[2]", - "path": param2_path, + "path": os.fspath(param2_path), "lineno": "6", "type_": "test", - "id_": "param_same_name/test_param2.py::test_odd_even[2]", - "runID": "param_same_name/test_param2.py::test_odd_even[2]", + "id_": get_absolute_test_id( + "param_same_name/test_param2.py::test_odd_even[2]", + param2_path, + ), + "runID": get_absolute_test_id( + "param_same_name/test_param2.py::test_odd_even[2]", + param2_path, + ), }, { "name": "[3]", - "path": param2_path, + "path": os.fspath(param2_path), "lineno": "6", "type_": "test", - "id_": "param_same_name/test_param2.py::test_odd_even[3]", - "runID": "param_same_name/test_param2.py::test_odd_even[3]", + "id_": get_absolute_test_id( + "param_same_name/test_param2.py::test_odd_even[3]", + param2_path, + ), + "runID": get_absolute_test_id( + "param_same_name/test_param2.py::test_odd_even[3]", + param2_path, + ), }, ], "id_": "param_same_name/test_param2.py::test_odd_even", @@ -658,3 +822,67 @@ ], "id_": TEST_DATA_PATH_STR, } + +tests_path = TEST_DATA_PATH / "root" / "tests" +tests_a_path = TEST_DATA_PATH / "root" / "tests" / "test_a.py" +tests_b_path = TEST_DATA_PATH / "root" / "tests" / "test_b.py" +# This is the expected output for the root folder tests. +# └── tests +# └── test_a.py +# └── test_a_function +# └── test_b.py +# └── test_b_function +root_with_config_expected_output = { + "name": "tests", + "path": os.fspath(tests_path), + "type_": "folder", + "children": [ + { + "name": "test_a.py", + "path": os.fspath(tests_a_path), + "type_": "file", + "id_": os.fspath(tests_a_path), + "children": [ + { + "name": "test_a_function", + "path": os.fspath(os.path.join(tests_path, "test_a.py")), + "lineno": find_test_line_number( + "test_a_function", + os.path.join(tests_path, "test_a.py"), + ), + "type_": "test", + "id_": get_absolute_test_id( + "tests/test_a.py::test_a_function", tests_a_path + ), + "runID": get_absolute_test_id( + "tests/test_a.py::test_a_function", tests_a_path + ), + } + ], + }, + { + "name": "test_b.py", + "path": os.fspath(tests_b_path), + "type_": "file", + "id_": os.fspath(tests_b_path), + "children": [ + { + "name": "test_b_function", + "path": os.fspath(os.path.join(tests_path, "test_b.py")), + "lineno": find_test_line_number( + "test_b_function", + os.path.join(tests_path, "test_b.py"), + ), + "type_": "test", + "id_": get_absolute_test_id( + "tests/test_b.py::test_b_function", tests_b_path + ), + "runID": get_absolute_test_id( + "tests/test_b.py::test_b_function", tests_b_path + ), + } + ], + }, + ], + "id_": os.fspath(tests_path), +} diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_execution_test_output.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_execution_test_output.py index fe1d40a55b4..76d21b3e251 100644 --- a/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_execution_test_output.py +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_execution_test_output.py @@ -1,11 +1,11 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from .helpers import TEST_DATA_PATH, get_absolute_test_id TEST_SUBTRACT_FUNCTION = "unittest_folder/test_subtract.py::TestSubtractFunction::" TEST_ADD_FUNCTION = "unittest_folder/test_add.py::TestAddFunction::" SUCCESS = "success" FAILURE = "failure" -TEST_SUBTRACT_FUNCTION_NEGATIVE_NUMBERS_ERROR = "self = \n\n def test_subtract_negative_numbers( # test_marker--test_subtract_negative_numbers\n self,\n ):\n result = subtract(-2, -3)\n> self.assertEqual(result, 100000)\nE AssertionError: 1 != 100000\n\nunittest_folder/test_subtract.py:25: AssertionError" # This is the expected output for the unittest_folder execute tests # └── unittest_folder @@ -17,30 +17,52 @@ # └── TestSubtractFunction # ├── test_subtract_negative_numbers: failure # └── test_subtract_positive_numbers: success +test_add_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py" +test_subtract_path = TEST_DATA_PATH / "unittest_folder" / "test_subtract.py" uf_execution_expected_output = { - f"{TEST_ADD_FUNCTION}test_add_negative_numbers": { - "test": f"{TEST_ADD_FUNCTION}test_add_negative_numbers", + get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_negative_numbers", test_add_path + ): { + "test": get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_negative_numbers", test_add_path + ), "outcome": SUCCESS, "message": None, "traceback": None, "subtest": None, }, - f"{TEST_ADD_FUNCTION}test_add_positive_numbers": { - "test": f"{TEST_ADD_FUNCTION}test_add_positive_numbers", + get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ): { + "test": get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ), "outcome": SUCCESS, "message": None, "traceback": None, "subtest": None, }, - f"{TEST_SUBTRACT_FUNCTION}test_subtract_negative_numbers": { - "test": f"{TEST_SUBTRACT_FUNCTION}test_subtract_negative_numbers", + get_absolute_test_id( + f"{TEST_SUBTRACT_FUNCTION}test_subtract_negative_numbers", + test_subtract_path, + ): { + "test": get_absolute_test_id( + f"{TEST_SUBTRACT_FUNCTION}test_subtract_negative_numbers", + test_subtract_path, + ), "outcome": FAILURE, "message": "ERROR MESSAGE", "traceback": None, "subtest": None, }, - f"{TEST_SUBTRACT_FUNCTION}test_subtract_positive_numbers": { - "test": f"{TEST_SUBTRACT_FUNCTION}test_subtract_positive_numbers", + get_absolute_test_id( + f"{TEST_SUBTRACT_FUNCTION}test_subtract_positive_numbers", + test_subtract_path, + ): { + "test": get_absolute_test_id( + f"{TEST_SUBTRACT_FUNCTION}test_subtract_positive_numbers", + test_subtract_path, + ), "outcome": SUCCESS, "message": None, "traceback": None, @@ -55,16 +77,26 @@ # │ └── TestAddFunction # │ ├── test_add_negative_numbers: success # │ └── test_add_positive_numbers: success +test_add_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py" + uf_single_file_expected_output = { - f"{TEST_ADD_FUNCTION}test_add_negative_numbers": { - "test": f"{TEST_ADD_FUNCTION}test_add_negative_numbers", + get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_negative_numbers", test_add_path + ): { + "test": get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_negative_numbers", test_add_path + ), "outcome": SUCCESS, "message": None, "traceback": None, "subtest": None, }, - f"{TEST_ADD_FUNCTION}test_add_positive_numbers": { - "test": f"{TEST_ADD_FUNCTION}test_add_positive_numbers", + get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ): { + "test": get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ), "outcome": SUCCESS, "message": None, "traceback": None, @@ -72,19 +104,24 @@ }, } + # This is the expected output for the unittest_folder execute only signle method # └── unittest_folder # ├── test_add.py # │ └── TestAddFunction # │ └── test_add_positive_numbers: success uf_single_method_execution_expected_output = { - f"{TEST_ADD_FUNCTION}test_add_positive_numbers": { - "test": f"{TEST_ADD_FUNCTION}test_add_positive_numbers", + get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ): { + "test": get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ), "outcome": SUCCESS, "message": None, "traceback": None, "subtest": None, - } + }, } # This is the expected output for the unittest_folder tests run where two tests @@ -96,18 +133,28 @@ # └── test_subtract.py # └── TestSubtractFunction # └── test_subtract_positive_numbers: success +test_subtract_path = TEST_DATA_PATH / "unittest_folder" / "test_subtract.py" +test_add_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py" + uf_non_adjacent_tests_execution_expected_output = { - TEST_SUBTRACT_FUNCTION - + "test_subtract_positive_numbers": { - "test": TEST_SUBTRACT_FUNCTION + "test_subtract_positive_numbers", + get_absolute_test_id( + f"{TEST_SUBTRACT_FUNCTION}test_subtract_positive_numbers", test_subtract_path + ): { + "test": get_absolute_test_id( + f"{TEST_SUBTRACT_FUNCTION}test_subtract_positive_numbers", + test_subtract_path, + ), "outcome": SUCCESS, "message": None, "traceback": None, "subtest": None, }, - TEST_ADD_FUNCTION - + "test_add_positive_numbers": { - "test": TEST_ADD_FUNCTION + "test_add_positive_numbers", + get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ): { + "test": get_absolute_test_id( + f"{TEST_ADD_FUNCTION}test_add_positive_numbers", test_add_path + ), "outcome": SUCCESS, "message": None, "traceback": None, @@ -115,12 +162,15 @@ }, } + # This is the expected output for the simple_pytest.py file. # └── simple_pytest.py # └── test_function: success +simple_pytest_path = TEST_DATA_PATH / "unittest_folder" / "simple_pytest.py" + simple_execution_pytest_expected_output = { - "simple_pytest.py::test_function": { - "test": "simple_pytest.py::test_function", + get_absolute_test_id("test_function", simple_pytest_path): { + "test": get_absolute_test_id("test_function", simple_pytest_path), "outcome": "success", "message": None, "traceback": None, @@ -128,21 +178,34 @@ } } + # This is the expected output for the unittest_pytest_same_file.py file. # ├── unittest_pytest_same_file.py # ├── TestExample # │ └── test_true_unittest: success # └── test_true_pytest: success +unit_pytest_same_file_path = TEST_DATA_PATH / "unittest_pytest_same_file.py" unit_pytest_same_file_execution_expected_output = { - "unittest_pytest_same_file.py::TestExample::test_true_unittest": { - "test": "unittest_pytest_same_file.py::TestExample::test_true_unittest", + get_absolute_test_id( + "unittest_pytest_same_file.py::TestExample::test_true_unittest", + unit_pytest_same_file_path, + ): { + "test": get_absolute_test_id( + "unittest_pytest_same_file.py::TestExample::test_true_unittest", + unit_pytest_same_file_path, + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "unittest_pytest_same_file.py::test_true_pytest": { - "test": "unittest_pytest_same_file.py::test_true_pytest", + get_absolute_test_id( + "unittest_pytest_same_file.py::test_true_pytest", unit_pytest_same_file_path + ): { + "test": get_absolute_test_id( + "unittest_pytest_same_file.py::test_true_pytest", + unit_pytest_same_file_path, + ), "outcome": "success", "message": None, "traceback": None, @@ -154,9 +217,15 @@ # └── error_raise_exception.py # ├── TestSomething # │ └── test_a: failure +error_raised_exception_path = TEST_DATA_PATH / "error_raise_exception.py" error_raised_exception_execution_expected_output = { - "error_raise_exception.py::TestSomething::test_a": { - "test": "error_raise_exception.py::TestSomething::test_a", + get_absolute_test_id( + "error_raise_exception.py::TestSomething::test_a", error_raised_exception_path + ): { + "test": get_absolute_test_id( + "error_raise_exception.py::TestSomething::test_a", + error_raised_exception_path, + ), "outcome": "error", "message": "ERROR MESSAGE", "traceback": "TRACEBACK", @@ -172,44 +241,60 @@ # ├── TestClass # │ └── test_class_function_a: skipped # │ └── test_class_function_b: skipped + +skip_tests_path = TEST_DATA_PATH / "skip_tests.py" skip_tests_execution_expected_output = { - "skip_tests.py::test_something": { - "test": "skip_tests.py::test_something", + get_absolute_test_id("skip_tests.py::test_something", skip_tests_path): { + "test": get_absolute_test_id("skip_tests.py::test_something", skip_tests_path), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "skip_tests.py::test_another_thing": { - "test": "skip_tests.py::test_another_thing", + get_absolute_test_id("skip_tests.py::test_another_thing", skip_tests_path): { + "test": get_absolute_test_id( + "skip_tests.py::test_another_thing", skip_tests_path + ), "outcome": "skipped", "message": None, "traceback": None, "subtest": None, }, - "skip_tests.py::test_decorator_thing": { - "test": "skip_tests.py::test_decorator_thing", + get_absolute_test_id("skip_tests.py::test_decorator_thing", skip_tests_path): { + "test": get_absolute_test_id( + "skip_tests.py::test_decorator_thing", skip_tests_path + ), "outcome": "skipped", "message": None, "traceback": None, "subtest": None, }, - "skip_tests.py::test_decorator_thing_2": { - "test": "skip_tests.py::test_decorator_thing_2", + get_absolute_test_id("skip_tests.py::test_decorator_thing_2", skip_tests_path): { + "test": get_absolute_test_id( + "skip_tests.py::test_decorator_thing_2", skip_tests_path + ), "outcome": "skipped", "message": None, "traceback": None, "subtest": None, }, - "skip_tests.py::TestClass::test_class_function_a": { - "test": "skip_tests.py::TestClass::test_class_function_a", + get_absolute_test_id( + "skip_tests.py::TestClass::test_class_function_a", skip_tests_path + ): { + "test": get_absolute_test_id( + "skip_tests.py::TestClass::test_class_function_a", skip_tests_path + ), "outcome": "skipped", "message": None, "traceback": None, "subtest": None, }, - "skip_tests.py::TestClass::test_class_function_b": { - "test": "skip_tests.py::TestClass::test_class_function_b", + get_absolute_test_id( + "skip_tests.py::TestClass::test_class_function_b", skip_tests_path + ): { + "test": get_absolute_test_id( + "skip_tests.py::TestClass::test_class_function_b", skip_tests_path + ), "outcome": "skipped", "message": None, "traceback": None, @@ -227,30 +312,59 @@ # └── test_bottom_folder.py # └── test_bottom_function_t: success # └── test_bottom_function_f: failure +dual_level_nested_folder_top_path = ( + TEST_DATA_PATH / "dual_level_nested_folder" / "test_top_folder.py" +) +dual_level_nested_folder_bottom_path = ( + TEST_DATA_PATH + / "dual_level_nested_folder" + / "nested_folder_one" + / "test_bottom_folder.py" +) dual_level_nested_folder_execution_expected_output = { - "dual_level_nested_folder/test_top_folder.py::test_top_function_t": { - "test": "dual_level_nested_folder/test_top_folder.py::test_top_function_t", + get_absolute_test_id( + "test_top_folder.py::test_top_function_t", dual_level_nested_folder_top_path + ): { + "test": get_absolute_test_id( + "test_top_folder.py::test_top_function_t", dual_level_nested_folder_top_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "dual_level_nested_folder/test_top_folder.py::test_top_function_f": { - "test": "dual_level_nested_folder/test_top_folder.py::test_top_function_f", + get_absolute_test_id( + "test_top_folder.py::test_top_function_f", dual_level_nested_folder_top_path + ): { + "test": get_absolute_test_id( + "test_top_folder.py::test_top_function_f", dual_level_nested_folder_top_path + ), "outcome": "failure", "message": "ERROR MESSAGE", "traceback": None, "subtest": None, }, - "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t": { - "test": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + get_absolute_test_id( + "nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + dual_level_nested_folder_bottom_path, + ): { + "test": get_absolute_test_id( + "nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + dual_level_nested_folder_bottom_path, + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f": { - "test": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + get_absolute_test_id( + "nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + dual_level_nested_folder_bottom_path, + ): { + "test": get_absolute_test_id( + "nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + dual_level_nested_folder_bottom_path, + ), "outcome": "failure", "message": "ERROR MESSAGE", "traceback": None, @@ -264,38 +378,59 @@ # └── folder_a # └── test_nest.py # └── test_function: success + +nested_folder_path = ( + TEST_DATA_PATH / "folder_a" / "folder_b" / "folder_a" / "test_nest.py" +) double_nested_folder_expected_execution_output = { - "folder_a/folder_b/folder_a/test_nest.py::test_function": { - "test": "folder_a/folder_b/folder_a/test_nest.py::test_function", + get_absolute_test_id( + "folder_a/folder_b/folder_a/test_nest.py::test_function", nested_folder_path + ): { + "test": get_absolute_test_id( + "folder_a/folder_b/folder_a/test_nest.py::test_function", nested_folder_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, } } - # This is the expected output for the nested_folder tests. # └── parametrize_tests.py # └── test_adding[3+5-8]: success # └── test_adding[2+4-6]: success # └── test_adding[6+9-16]: failure +parametrize_tests_path = TEST_DATA_PATH / "parametrize_tests.py" + parametrize_tests_expected_execution_output = { - "parametrize_tests.py::test_adding[3+5-8]": { - "test": "parametrize_tests.py::test_adding[3+5-8]", + get_absolute_test_id( + "parametrize_tests.py::test_adding[3+5-8]", parametrize_tests_path + ): { + "test": get_absolute_test_id( + "parametrize_tests.py::test_adding[3+5-8]", parametrize_tests_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "parametrize_tests.py::test_adding[2+4-6]": { - "test": "parametrize_tests.py::test_adding[2+4-6]", + get_absolute_test_id( + "parametrize_tests.py::test_adding[2+4-6]", parametrize_tests_path + ): { + "test": get_absolute_test_id( + "parametrize_tests.py::test_adding[2+4-6]", parametrize_tests_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "parametrize_tests.py::test_adding[6+9-16]": { - "test": "parametrize_tests.py::test_adding[6+9-16]", + get_absolute_test_id( + "parametrize_tests.py::test_adding[6+9-16]", parametrize_tests_path + ): { + "test": get_absolute_test_id( + "parametrize_tests.py::test_adding[6+9-16]", parametrize_tests_path + ), "outcome": "failure", "message": "ERROR MESSAGE", "traceback": None, @@ -307,8 +442,12 @@ # └── parametrize_tests.py # └── test_adding[3+5-8]: success single_parametrize_tests_expected_execution_output = { - "parametrize_tests.py::test_adding[3+5-8]": { - "test": "parametrize_tests.py::test_adding[3+5-8]", + get_absolute_test_id( + "parametrize_tests.py::test_adding[3+5-8]", parametrize_tests_path + ): { + "test": get_absolute_test_id( + "parametrize_tests.py::test_adding[3+5-8]", parametrize_tests_path + ), "outcome": "success", "message": None, "traceback": None, @@ -319,9 +458,12 @@ # This is the expected output for the single parameterized tests. # └── text_docstring.txt # └── text_docstring: success +doc_test_path = TEST_DATA_PATH / "text_docstring.txt" doctest_pytest_expected_execution_output = { - "text_docstring.txt::text_docstring.txt": { - "test": "text_docstring.txt::text_docstring.txt", + get_absolute_test_id("text_docstring.txt::text_docstring.txt", doc_test_path): { + "test": get_absolute_test_id( + "text_docstring.txt::text_docstring.txt", doc_test_path + ), "outcome": "success", "message": None, "traceback": None, @@ -330,68 +472,127 @@ } # Will run all tests in the cwd that fit the test file naming pattern. +folder_a_path = TEST_DATA_PATH / "folder_a" / "folder_b" / "folder_a" / "test_nest.py" +dual_level_nested_folder_top_path = ( + TEST_DATA_PATH / "dual_level_nested_folder" / "test_top_folder.py" +) +dual_level_nested_folder_bottom_path = ( + TEST_DATA_PATH + / "dual_level_nested_folder" + / "nested_folder_one" + / "test_bottom_folder.py" +) +unittest_folder_add_path = TEST_DATA_PATH / "unittest_folder" / "test_add.py" +unittest_folder_subtract_path = TEST_DATA_PATH / "unittest_folder" / "test_subtract.py" + no_test_ids_pytest_execution_expected_output = { - "folder_a/folder_b/folder_a/test_nest.py::test_function": { - "test": "folder_a/folder_b/folder_a/test_nest.py::test_function", + get_absolute_test_id("test_function", folder_a_path): { + "test": get_absolute_test_id("test_function", folder_a_path), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "dual_level_nested_folder/test_top_folder.py::test_top_function_t": { - "test": "dual_level_nested_folder/test_top_folder.py::test_top_function_t", + get_absolute_test_id("test_top_function_t", dual_level_nested_folder_top_path): { + "test": get_absolute_test_id( + "test_top_function_t", dual_level_nested_folder_top_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "dual_level_nested_folder/test_top_folder.py::test_top_function_f": { - "test": "dual_level_nested_folder/test_top_folder.py::test_top_function_f", + get_absolute_test_id("test_top_function_f", dual_level_nested_folder_top_path): { + "test": get_absolute_test_id( + "test_top_function_f", dual_level_nested_folder_top_path + ), "outcome": "failure", "message": "ERROR MESSAGE", "traceback": None, "subtest": None, }, - "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t": { - "test": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + get_absolute_test_id( + "test_bottom_function_t", dual_level_nested_folder_bottom_path + ): { + "test": get_absolute_test_id( + "test_bottom_function_t", dual_level_nested_folder_bottom_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f": { - "test": "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + get_absolute_test_id( + "test_bottom_function_f", dual_level_nested_folder_bottom_path + ): { + "test": get_absolute_test_id( + "test_bottom_function_f", dual_level_nested_folder_bottom_path + ), "outcome": "failure", "message": "ERROR MESSAGE", "traceback": None, "subtest": None, }, - "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers": { - "test": "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", + get_absolute_test_id( + "TestAddFunction::test_add_negative_numbers", unittest_folder_add_path + ): { + "test": get_absolute_test_id( + "TestAddFunction::test_add_negative_numbers", unittest_folder_add_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers": { - "test": "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + get_absolute_test_id( + "TestAddFunction::test_add_positive_numbers", unittest_folder_add_path + ): { + "test": get_absolute_test_id( + "TestAddFunction::test_add_positive_numbers", unittest_folder_add_path + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, - "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers": { - "test": "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + get_absolute_test_id( + "TestSubtractFunction::test_subtract_negative_numbers", + unittest_folder_subtract_path, + ): { + "test": get_absolute_test_id( + "TestSubtractFunction::test_subtract_negative_numbers", + unittest_folder_subtract_path, + ), "outcome": "failure", "message": "ERROR MESSAGE", "traceback": None, "subtest": None, }, - "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers": { - "test": "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + get_absolute_test_id( + "TestSubtractFunction::test_subtract_positive_numbers", + unittest_folder_subtract_path, + ): { + "test": get_absolute_test_id( + "TestSubtractFunction::test_subtract_positive_numbers", + unittest_folder_subtract_path, + ), "outcome": "success", "message": None, "traceback": None, "subtest": None, }, } + +# This is the expected output for the root folder with the config file referenced. +# └── test_a.py +# └── test_a_function: success +test_add_path = TEST_DATA_PATH / "root" / "tests" / "test_a.py" +config_file_pytest_expected_execution_output = { + get_absolute_test_id("tests/test_a.py::test_a_function", test_add_path): { + "test": get_absolute_test_id("tests/test_a.py::test_a_function", test_add_path), + "outcome": "success", + "message": None, + "traceback": None, + "subtest": None, + } +} diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/helpers.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/helpers.py index c3e01d52170..7195cfe43ea 100644 --- a/extensions/positron-python/pythonFiles/tests/pytestadapter/helpers.py +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/helpers.py @@ -10,12 +10,19 @@ import sys import threading import uuid -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple TEST_DATA_PATH = pathlib.Path(__file__).parent / ".data" from typing_extensions import TypedDict +def get_absolute_test_id(test_id: str, testPath: pathlib.Path) -> str: + split_id = test_id.split("::")[1:] + absolute_test_id = "::".join([str(testPath), *split_id]) + print("absolute path", absolute_test_id) + return absolute_test_id + + def create_server( host: str = "127.0.0.1", port: int = 0, @@ -104,6 +111,13 @@ def process_rpc_json(data: str) -> List[Dict[str, Any]]: def runner(args: List[str]) -> Optional[List[Dict[str, Any]]]: + """Run the pytest discovery and return the JSON data from the server.""" + return runner_with_cwd(args, TEST_DATA_PATH) + + +def runner_with_cwd( + args: List[str], path: pathlib.Path +) -> Optional[List[Dict[str, Any]]]: """Run the pytest discovery and return the JSON data from the server.""" process_args: List[str] = [ sys.executable, @@ -134,7 +148,7 @@ def runner(args: List[str]) -> Optional[List[Dict[str, Any]]]: t2 = threading.Thread( target=_run_test_code, - args=(process_args, env, TEST_DATA_PATH, completed), + args=(process_args, env, path, completed), ) t2.start() diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py index 02ea1ddcd87..8d785be27c8 100644 --- a/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py @@ -7,7 +7,7 @@ import pytest from . import expected_discovery_test_output -from .helpers import TEST_DATA_PATH, runner +from .helpers import TEST_DATA_PATH, runner, runner_with_cwd def test_import_error(tmp_path): @@ -87,6 +87,10 @@ def test_parameterized_error_collect(): @pytest.mark.parametrize( "file, expected_const", [ + ( + "unittest_skiptest_file_level.py", + expected_discovery_test_output.unittest_skip_file_level_expected_output, + ), ( "param_same_name", expected_discovery_test_output.param_same_name_expected_output, @@ -149,3 +153,53 @@ def test_pytest_collect(file, expected_const): assert actual["status"] == "success" assert actual["cwd"] == os.fspath(TEST_DATA_PATH) assert actual["tests"] == expected_const + + +def test_pytest_root_dir(): + """ + Test to test pytest discovery with the command line arg --rootdir specified to be a subfolder + of the workspace root. Discovery should succeed and testids should be relative to workspace root. + """ + rd = f"--rootdir={TEST_DATA_PATH / 'root' / 'tests'}" + actual = runner_with_cwd( + [ + "--collect-only", + rd, + ], + TEST_DATA_PATH / "root", + ) + if actual: + actual = actual[0] + assert actual + assert all(item in actual for item in ("status", "cwd", "tests")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH / "root") + assert ( + actual["tests"] + == expected_discovery_test_output.root_with_config_expected_output + ) + + +def test_pytest_config_file(): + """ + Test to test pytest discovery with the command line arg -c with a specified config file which + changes the workspace root. Discovery should succeed and testids should be relative to workspace root. + """ + actual = runner_with_cwd( + [ + "--collect-only", + "-c", + "tests/pytest.ini", + ], + TEST_DATA_PATH / "root", + ) + if actual: + actual = actual[0] + assert actual + assert all(item in actual for item in ("status", "cwd", "tests")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH / "root") + assert ( + actual["tests"] + == expected_discovery_test_output.root_with_config_expected_output + ) diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py index ffc84955bf5..07354b01709 100644 --- a/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py @@ -4,9 +4,53 @@ import shutil import pytest + from tests.pytestadapter import expected_execution_test_output -from .helpers import TEST_DATA_PATH, runner +from .helpers import TEST_DATA_PATH, runner, runner_with_cwd + + +def test_config_file(): + """Test pytest execution when a config file is specified.""" + args = [ + "-c", + "tests/pytest.ini", + str(TEST_DATA_PATH / "root" / "tests" / "test_a.py::test_a_function"), + ] + new_cwd = TEST_DATA_PATH / "root" + actual = runner_with_cwd(args, new_cwd) + expected_const = ( + expected_execution_test_output.config_file_pytest_expected_execution_output + ) + assert actual + assert len(actual) == len(expected_const) + actual_result_dict = dict() + for a in actual: + assert all(item in a for item in ("status", "cwd", "result")) + assert a["status"] == "success" + assert a["cwd"] == os.fspath(new_cwd) + actual_result_dict.update(a["result"]) + assert actual_result_dict == expected_const + + +def test_rootdir_specified(): + """Test pytest execution when a --rootdir is specified.""" + rd = f"--rootdir={TEST_DATA_PATH / 'root' / 'tests'}" + args = [rd, "tests/test_a.py::test_a_function"] + new_cwd = TEST_DATA_PATH / "root" + actual = runner_with_cwd(args, new_cwd) + expected_const = ( + expected_execution_test_output.config_file_pytest_expected_execution_output + ) + assert actual + assert len(actual) == len(expected_const) + actual_result_dict = dict() + for a in actual: + assert all(item in a for item in ("status", "cwd", "result")) + assert a["status"] == "success" + assert a["cwd"] == os.fspath(new_cwd) + actual_result_dict.update(a["result"]) + assert actual_result_dict == expected_const def test_syntax_error_execution(tmp_path): @@ -161,7 +205,7 @@ def test_pytest_execution(test_ids, expected_const): Keyword arguments: test_ids -- an array of test_ids to run. expected_const -- a dictionary of the expected output from running pytest discovery on the files. - """ + """ # noqa: E501 args = test_ids actual = runner(args) assert actual @@ -179,6 +223,6 @@ def test_pytest_execution(test_ids, expected_const): or actual_result_dict[key]["outcome"] == "error" ): actual_result_dict[key]["message"] = "ERROR MESSAGE" - if actual_result_dict[key]["traceback"] != None: + if actual_result_dict[key]["traceback"] is not None: actual_result_dict[key]["traceback"] = "TRACEBACK" assert actual_result_dict == expected_const diff --git a/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py b/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py index f123052c491..e5d4e68802e 100644 --- a/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py +++ b/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py @@ -6,7 +6,6 @@ import sys import create_microvenv -import pytest def test_create_microvenv(): @@ -26,4 +25,4 @@ def run_process(args, error_message): create_microvenv.run_process = run_process create_microvenv.main() - assert run_process_called == True + assert run_process_called is True diff --git a/extensions/positron-python/pythonFiles/tests/test_create_venv.py b/extensions/positron-python/pythonFiles/tests/test_create_venv.py index bebe304c13c..ae3f18be6f3 100644 --- a/extensions/positron-python/pythonFiles/tests/test_create_venv.py +++ b/extensions/positron-python/pythonFiles/tests/test_create_venv.py @@ -5,9 +5,10 @@ import os import sys -import create_venv import pytest +import create_venv + @pytest.mark.skipif( sys.platform == "win32", reason="Windows does not have micro venv fallback." @@ -35,7 +36,7 @@ def run_process(args, error_message): create_venv.main(["--name", ".test_venv"]) # run_process is called when the venv does not exist - assert run_process_called == True + assert run_process_called is True @pytest.mark.skipif( diff --git a/extensions/positron-python/pythonFiles/tests/test_installed_check.py b/extensions/positron-python/pythonFiles/tests/test_installed_check.py index f76070d197b..dae019359e0 100644 --- a/extensions/positron-python/pythonFiles/tests/test_installed_check.py +++ b/extensions/positron-python/pythonFiles/tests/test_installed_check.py @@ -9,7 +9,7 @@ import sys import pytest -from typing import Dict, List, Union +from typing import Dict, List, Optional, Union SCRIPT_PATH = pathlib.Path(__file__).parent.parent / "installed_check.py" TEST_DATA = pathlib.Path(__file__).parent / "test_data" @@ -29,7 +29,12 @@ def generate_file(base_file: pathlib.Path): os.unlink(str(fullpath)) -def run_on_file(file_path: pathlib.Path) -> List[Dict[str, Union[str, int]]]: +def run_on_file( + file_path: pathlib.Path, severity: Optional[str] = None +) -> List[Dict[str, Union[str, int]]]: + env = os.environ.copy() + if severity: + env["VSCODE_MISSING_PGK_SEVERITY"] = severity result = subprocess.run( [ sys.executable, @@ -39,6 +44,7 @@ def run_on_file(file_path: pathlib.Path) -> List[Dict[str, Union[str, int]]]: stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True, + env=env, ) assert result.returncode == 0 assert result.stderr == b"" @@ -88,3 +94,46 @@ def test_installed_check(test_name: str): with generate_file(base_file) as file_path: result = run_on_file(file_path) assert result == EXPECTED_DATA[test_name] + + +EXPECTED_DATA2 = { + "missing-deps": [ + { + "line": 6, + "character": 0, + "endLine": 6, + "endCharacter": 10, + "package": "flake8-csv", + "code": "not-installed", + "severity": 0, + }, + { + "line": 10, + "character": 0, + "endLine": 10, + "endCharacter": 11, + "package": "levenshtein", + "code": "not-installed", + "severity": 0, + }, + ], + "pyproject-missing-deps": [ + { + "line": 8, + "character": 34, + "endLine": 8, + "endCharacter": 44, + "package": "flake8-csv", + "code": "not-installed", + "severity": 0, + } + ], +} + + +@pytest.mark.parametrize("test_name", EXPECTED_DATA2.keys()) +def test_with_severity(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, severity="0") + assert result == EXPECTED_DATA2[test_name] diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/discovery_error/file_one.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/discovery_error/file_one.py index 42f84f04676..031b6f6c9d6 100644 --- a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/discovery_error/file_one.py +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/discovery_error/file_one.py @@ -3,7 +3,7 @@ import unittest -import something_else # type: ignore +import something_else # type: ignore # noqa: F401 class DiscoveryErrorOne(unittest.TestCase): diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_skip/unittest_skip_file.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_skip/unittest_skip_file.py new file mode 100644 index 00000000000..927a56bc920 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_skip/unittest_skip_file.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from unittest import SkipTest + +raise SkipTest("This is unittest.SkipTest calling") + + +def test_example(): + assert 1 == 1 diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_skip/unittest_skip_function.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_skip/unittest_skip_function.py new file mode 100644 index 00000000000..59e66e9a1d4 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_skip/unittest_skip_function.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + + +def add(x, y): + return x + y + + +class SimpleTest(unittest.TestCase): + @unittest.skip("demonstrating skipping") + def testadd1(self): + self.assertEquals(add(4, 5), 9) + + +if __name__ == "__main__": + unittest.main() diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/expected_discovery_test_output.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/expected_discovery_test_output.py new file mode 100644 index 00000000000..3043ec158a2 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/expected_discovery_test_output.py @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +from unittestadapter.utils import TestNodeTypeEnum +from .helpers import TEST_DATA_PATH + +skip_unittest_folder_discovery_output = { + "path": os.fspath(TEST_DATA_PATH / "unittest_skip"), + "name": "unittest_skip", + "type_": TestNodeTypeEnum.folder, + "children": [ + { + "path": os.fspath( + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_file.py" + ), + "name": "unittest_skip_file.py", + "type_": TestNodeTypeEnum.file, + "children": [], + "id_": os.fspath( + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_file.py" + ), + }, + { + "path": os.fspath( + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py" + ), + "name": "unittest_skip_function.py", + "type_": TestNodeTypeEnum.file, + "children": [ + { + "path": os.fspath( + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py" + ), + "name": "SimpleTest", + "type_": TestNodeTypeEnum.class_, + "children": [ + { + "name": "testadd1", + "path": os.fspath( + TEST_DATA_PATH + / "unittest_skip" + / "unittest_skip_function.py" + ), + "lineno": "13", + "type_": TestNodeTypeEnum.test, + "id_": os.fspath( + TEST_DATA_PATH + / "unittest_skip" + / "unittest_skip_function.py" + ) + + "\\SimpleTest\\testadd1", + "runID": "unittest_skip_function.SimpleTest.testadd1", + } + ], + "id_": os.fspath( + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py" + ) + + "\\SimpleTest", + } + ], + "id_": os.fspath( + TEST_DATA_PATH / "unittest_skip" / "unittest_skip_function.py" + ), + }, + ], + "id_": os.fspath(TEST_DATA_PATH / "unittest_skip"), +} diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/test_discovery.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/test_discovery.py index 28dc51f55dc..c4778aa8585 100644 --- a/extensions/positron-python/pythonFiles/tests/unittestadapter/test_discovery.py +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/test_discovery.py @@ -12,7 +12,7 @@ parse_discovery_cli_args, ) from unittestadapter.utils import TestNodeTypeEnum, parse_unittest_args - +from . import expected_discovery_test_output from .helpers import TEST_DATA_PATH, is_same_tree @@ -214,3 +214,22 @@ def test_error_discovery() -> None: assert actual["status"] == "error" assert is_same_tree(expected, actual.get("tests")) assert len(actual.get("error", [])) == 1 + + +def test_unit_skip() -> None: + """The discover_tests function should return a dictionary with a "success" status, a uuid, no errors, and test tree. + if unittest discovery was performed and found a test in one file marked as skipped and another file marked as skipped. + """ + start_dir = os.fsdecode(TEST_DATA_PATH / "unittest_skip") + pattern = "unittest_*" + + uuid = "some-uuid" + actual = discover_tests(start_dir, pattern, None, uuid) + + assert actual["status"] == "success" + assert "tests" in actual + assert is_same_tree( + actual.get("tests"), + expected_discovery_test_output.skip_unittest_folder_discovery_output, + ) + assert "error" not in actual diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/test_utils.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/test_utils.py index a3bc1dd7693..e262f877d52 100644 --- a/extensions/positron-python/pythonFiles/tests/unittestadapter/test_utils.py +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/test_utils.py @@ -6,6 +6,7 @@ import unittest import pytest + from unittestadapter.utils import ( TestNode, TestNodeTypeEnum, @@ -284,7 +285,8 @@ def test_build_decorated_tree() -> None: def test_build_empty_tree() -> None: - """The build_test_tree function should return None if there are no discovered test suites, and an empty list of errors if there are none in the discovered data.""" + """The build_test_tree function should return None if there are no discovered test suites, + and an empty list of errors if there are none in the discovered data.""" start_dir = os.fsdecode(TEST_DATA_PATH) pattern = "does_not_exist*" diff --git a/extensions/positron-python/pythonFiles/unittestadapter/execution.py b/extensions/positron-python/pythonFiles/unittestadapter/execution.py index dfb6928a207..f239f81c2d8 100644 --- a/extensions/positron-python/pythonFiles/unittestadapter/execution.py +++ b/extensions/positron-python/pythonFiles/unittestadapter/execution.py @@ -17,8 +17,9 @@ sys.path.append(os.fspath(script_dir)) sys.path.insert(0, os.fspath(script_dir / "lib" / "python")) -from testing_tools import process_json_util, socket_manager from typing_extensions import NotRequired, TypeAlias, TypedDict + +from testing_tools import process_json_util, socket_manager from unittestadapter.utils import parse_unittest_args DEFAULT_PORT = "45454" @@ -194,12 +195,12 @@ def run_tests( # Discover tests at path with the file name as a pattern (if any). loader = unittest.TestLoader() - args = { + args = { # noqa: F841 "start_dir": start_dir, "pattern": pattern, "top_level_dir": top_level_dir, } - suite = loader.discover(start_dir, pattern, top_level_dir) + suite = loader.discover(start_dir, pattern, top_level_dir) # noqa: F841 # Run tests. runner = unittest.TextTestRunner(resultclass=UnittestTestResult) diff --git a/extensions/positron-python/pythonFiles/unittestadapter/utils.py b/extensions/positron-python/pythonFiles/unittestadapter/utils.py index a461baf7d87..64f08217f38 100644 --- a/extensions/positron-python/pythonFiles/unittestadapter/utils.py +++ b/extensions/positron-python/pythonFiles/unittestadapter/utils.py @@ -60,11 +60,11 @@ def get_source_line(obj) -> str: """Get the line number of a test case start line.""" try: sourcelines, lineno = inspect.getsourcelines(obj) - except: + except Exception: try: # tornado-specific, see https://github.com/microsoft/vscode-python/issues/17285. sourcelines, lineno = inspect.getsourcelines(obj.orig_method) - except: + except Exception: return "*" # Return the line number of the first line of the test case definition. @@ -159,6 +159,14 @@ def build_test_tree( test_id = test_case.id() if test_id.startswith("unittest.loader._FailedTest"): error.append(str(test_case._exception)) # type: ignore + elif test_id.startswith("unittest.loader.ModuleSkipped"): + components = test_id.split(".") + class_name = f"{components[-1]}.py" + # Find/build class node. + file_path = os.fsdecode(os.path.join(directory_path, class_name)) + current_node = get_child_node( + class_name, file_path, TestNodeTypeEnum.file, root + ) else: # Get the static test path components: filename, class name and function name. components = test_id.split(".") @@ -218,7 +226,8 @@ def parse_unittest_args(args: List[str]) -> Tuple[str, str, Union[str, None]]: The returned tuple contains the following items - start_directory: The directory where to start discovery, defaults to . - pattern: The pattern to match test files, defaults to test*.py - - top_level_directory: The top-level directory of the project, defaults to None, and unittest will use start_directory behind the scenes. + - top_level_directory: The top-level directory of the project, defaults to None, + and unittest will use start_directory behind the scenes. """ arg_parser = argparse.ArgumentParser() diff --git a/extensions/positron-python/pythonFiles/vscode_datascience_helpers/tests/logParser.py b/extensions/positron-python/pythonFiles/vscode_datascience_helpers/tests/logParser.py index 767f837c513..e021853fee7 100644 --- a/extensions/positron-python/pythonFiles/vscode_datascience_helpers/tests/logParser.py +++ b/extensions/positron-python/pythonFiles/vscode_datascience_helpers/tests/logParser.py @@ -1,11 +1,10 @@ -from io import TextIOWrapper -import sys import argparse import os +from io import TextIOWrapper os.system("color") -from pathlib import Path import re +from pathlib import Path parser = argparse.ArgumentParser(description="Parse a test log into its parts") parser.add_argument("testlog", type=str, nargs=1, help="Log to parse") @@ -63,14 +62,14 @@ def splitByPid(testlog): pid = int(match.group(1)) # See if we've created a log for this pid or not - if not pid in pids: + if pid not in pids: pids.add(pid) logFile = "{}_{}.log".format(baseFile, pid) print("Writing to new log: " + logFile) logs[pid] = Path(logFile).open(mode="w") # Add this line to the log - if pid != None: + if pid is not None: logs[pid].write(line) # Close all of the open logs for key in logs: diff --git a/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py b/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py index 072c5ef5d3a..adf72c13411 100644 --- a/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py +++ b/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py @@ -46,6 +46,15 @@ def __init__(self, message): ERRORS = [] +IS_DISCOVERY = False +map_id_to_path = dict() +collected_tests_so_far = list() + + +def pytest_load_initial_conftests(early_config, parser, args): + if "--collect-only" in args: + global IS_DISCOVERY + IS_DISCOVERY = True def pytest_internalerror(excrepr, excinfo): @@ -69,10 +78,11 @@ def pytest_exception_interact(node, call, report): """ # call.excinfo is the captured exception of the call, if it raised as type ExceptionInfo. # call.excinfo.exconly() returns the exception as a string. - # See if it is during discovery or execution. - # if discovery, then add the error to error logs. - if type(report) == pytest.CollectReport: + # If it is during discovery, then add the error to error logs. + if IS_DISCOVERY: if call.excinfo and call.excinfo.typename != "AssertionError": + if report.outcome == "skipped" and "SkipTest" in str(call): + return ERRORS.append( call.excinfo.exconly() + "\n Check Python Test Logs for more details." ) @@ -81,11 +91,11 @@ def pytest_exception_interact(node, call, report): report.longreprtext + "\n Check Python Test Logs for more details." ) else: - # if execution, send this data that the given node failed. + # If during execution, send this data that the given node failed. report_value = "error" if call.excinfo.typename == "AssertionError": report_value = "failure" - node_id = str(node.nodeid) + node_id = get_absolute_test_id(node.nodeid, get_node_path(node)) if node_id not in collected_tests_so_far: collected_tests_so_far.append(node_id) item_result = create_test_outcome( @@ -104,6 +114,22 @@ def pytest_exception_interact(node, call, report): ) +def get_absolute_test_id(test_id: str, testPath: pathlib.Path) -> str: + """A function that returns the absolute test id. This is necessary because testIds are relative to the rootdir. + This does not work for our case since testIds when referenced during run time are relative to the instantiation + location. Absolute paths for testIds are necessary for the test tree ensures configurations that change the rootdir + of pytest are handled correctly. + + Keyword arguments: + test_id -- the pytest id of the test which is relative to the rootdir. + testPath -- the path to the file the test is located in, as a pathlib.Path object. + """ + split_id = test_id.split("::")[1:] + absolute_test_id = "::".join([str(testPath), *split_id]) + print("absolute path", absolute_test_id) + return absolute_test_id + + def pytest_keyboard_interrupt(excinfo): """A pytest hook that is called when a keyboard interrupt is raised. @@ -128,7 +154,7 @@ class TestOutcome(Dict): def create_test_outcome( - test: str, + testid: str, outcome: str, message: Union[str, None], traceback: Union[str, None], @@ -136,7 +162,7 @@ def create_test_outcome( ) -> TestOutcome: """A function that creates a TestOutcome object.""" return TestOutcome( - test=test, + test=testid, outcome=outcome, message=message, traceback=traceback, # TODO: traceback @@ -151,18 +177,6 @@ class testRunResultDict(Dict[str, Dict[str, TestOutcome]]): tests: Dict[str, TestOutcome] -IS_DISCOVERY = False - - -def pytest_load_initial_conftests(early_config, parser, args): - if "--collect-only" in args: - global IS_DISCOVERY - IS_DISCOVERY = True - - -collected_tests_so_far = list() - - def pytest_report_teststatus(report, config): """ A pytest hook that is called when a test is called. It is called 3 times per test, @@ -182,17 +196,21 @@ def pytest_report_teststatus(report, config): elif report.failed: report_value = "failure" message = report.longreprtext - node_id = str(report.nodeid) - if node_id not in collected_tests_so_far: - collected_tests_so_far.append(node_id) + node_path = map_id_to_path[report.nodeid] + if not node_path: + node_path = cwd + # Calculate the absolute test id and use this as the ID moving forward. + absolute_node_id = get_absolute_test_id(report.nodeid, node_path) + if absolute_node_id not in collected_tests_so_far: + collected_tests_so_far.append(absolute_node_id) item_result = create_test_outcome( - node_id, + absolute_node_id, report_value, message, traceback, ) collected_test = testRunResultDict() - collected_test[node_id] = item_result + collected_test[absolute_node_id] = item_result execution_post( os.fsdecode(cwd), "success", @@ -209,21 +227,22 @@ def pytest_report_teststatus(report, config): def pytest_runtest_protocol(item, nextitem): + map_id_to_path[item.nodeid] = get_node_path(item) skipped = check_skipped_wrapper(item) if skipped: - node_id = str(item.nodeid) + absolute_node_id = get_absolute_test_id(item.nodeid, get_node_path(item)) report_value = "skipped" cwd = pathlib.Path.cwd() - if node_id not in collected_tests_so_far: - collected_tests_so_far.append(node_id) + if absolute_node_id not in collected_tests_so_far: + collected_tests_so_far.append(absolute_node_id) item_result = create_test_outcome( - node_id, + absolute_node_id, report_value, None, None, ) collected_test = testRunResultDict() - collected_test[node_id] = item_result + collected_test[absolute_node_id] = item_result execution_post( os.fsdecode(cwd), "success", @@ -469,13 +488,14 @@ def create_test_node( test_case_loc: str = ( str(test_case.location[1] + 1) if (test_case.location[1] is not None) else "" ) + absolute_test_id = get_absolute_test_id(test_case.nodeid, get_node_path(test_case)) return { "name": test_case.name, "path": get_node_path(test_case), "lineno": test_case_loc, "type_": "test", - "id_": test_case.nodeid, - "runID": test_case.nodeid, + "id_": absolute_test_id, + "runID": absolute_test_id, } diff --git a/extensions/positron-python/requirements.txt b/extensions/positron-python/requirements.txt index 2747490fdba..f2af0ca4204 100644 --- a/extensions/positron-python/requirements.txt +++ b/extensions/positron-python/requirements.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes requirements.in # -importlib-metadata==6.6.0 \ - --hash=sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed \ - --hash=sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705 +importlib-metadata==6.7.0 \ + --hash=sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4 \ + --hash=sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5 # via -r requirements.in microvenv==2023.2.0 \ --hash=sha256:5b46296d6a65992946da504bd9e724a5becf5c256091f2f9383e5b4e9f567f23 \ @@ -23,7 +23,9 @@ tomli==2.0.1 \ typing-extensions==4.7.1 \ --hash=sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36 \ --hash=sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2 - # via -r requirements.in + # via + # -r requirements.in + # importlib-metadata zipp==3.15.0 \ --hash=sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b \ --hash=sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556 diff --git a/extensions/positron-python/scripts/onCreateCommand.sh b/extensions/positron-python/scripts/onCreateCommand.sh new file mode 100644 index 00000000000..a90a5366417 --- /dev/null +++ b/extensions/positron-python/scripts/onCreateCommand.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Install pyenv and Python versions here to avoid using shim. +curl https://pyenv.run | bash +echo 'export PYENV_ROOT="$HOME/.pyenv"' >> ~/.bashrc +echo 'command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"' >> ~/.bashrc +# echo 'eval "$(pyenv init -)"' >> ~/.bashrc + +export PYENV_ROOT="$HOME/.pyenv" +command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH" +# eval "$(pyenv init -)" Comment this out and DO NOT use shim. +source ~/.bashrc + +# Install Python via pyenv . +pyenv install 3.7:latest 3.8:latest 3.9:latest 3.10:latest 3.11:latest + +# Set default Python version to 3.7 . +pyenv global 3.7.17 + +npm ci + +# Create Virutal environment. +pyenv exec python3.7 -m venv .venv + +# Activate Virtual environment. +source /workspaces/vscode-python/.venv/bin/activate + +# Install required Python libraries. +npx gulp installPythonLibs + +/workspaces/vscode-python/.venv/bin/python -m pip install -r build/test-requirements.txt +/workspaces/vscode-python/.venv/bin/python -m pip install -r build/functional-test-requirements.txt + +# Below will crash codespace +# npm run compile diff --git a/extensions/positron-python/scripts/postCreateCommand.sh b/extensions/positron-python/scripts/postCreateCommand.sh deleted file mode 100644 index 85462caf7fa..00000000000 --- a/extensions/positron-python/scripts/postCreateCommand.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -npm ci -# Create Virutal environment. -python3.7 -m venv /workspaces/vscode-python/.venv - -# Activate Virtual environment. -source /workspaces/vscode-python/.venv/bin/activate - -# Install required Python libraries. -npx gulp installPythonLibs - -# Install testing requirement using python in .venv . -/workspaces/vscode-python/.venv/bin/python -m pip install -r build/test-requirements.txt -/workspaces/vscode-python/.venv/bin/python -m pip install -r build/smoke-test-requirements.txt -/workspaces/vscode-python/.venv/bin/python -m pip install -r build/functional-test-requirements.txt diff --git a/extensions/positron-python/src/client/activation/extensionSurvey.ts b/extensions/positron-python/src/client/activation/extensionSurvey.ts index 6d1d784237b..11b581a2725 100644 --- a/extensions/positron-python/src/client/activation/extensionSurvey.ts +++ b/extensions/positron-python/src/client/activation/extensionSurvey.ts @@ -83,10 +83,10 @@ export class ExtensionSurveyPrompt implements IExtensionSingleActivationService @traceDecoratorError('Failed to display prompt for extension survey') public async showSurvey() { const prompts = [ExtensionSurveyBanner.bannerLabelYes, ExtensionSurveyBanner.maybeLater, Common.doNotShowAgain]; - const telemetrySelections: ['Yes', 'Maybe later', 'Do not show again'] = [ + const telemetrySelections: ['Yes', 'Maybe later', "Don't show again"] = [ 'Yes', 'Maybe later', - 'Do not show again', + "Don't show again", ]; const selection = await this.appShell.showInformationMessage(ExtensionSurveyBanner.bannerMessage, ...prompts); sendTelemetryEvent(EventName.EXTENSION_SURVEY_PROMPT, undefined, { diff --git a/extensions/positron-python/src/client/api.ts b/extensions/positron-python/src/client/api.ts index 32ce68f6a28..23b2553c93d 100644 --- a/extensions/positron-python/src/client/api.ts +++ b/extensions/positron-python/src/client/api.ts @@ -4,7 +4,6 @@ 'use strict'; -import { noop } from 'lodash'; import { Uri, Event } from 'vscode'; import { BaseLanguageClient, LanguageClientOptions } from 'vscode-languageclient'; import { LanguageClient } from 'vscode-languageclient/node'; @@ -17,7 +16,6 @@ import { getDebugpyLauncherArgs, getDebugpyPackagePath } from './debugger/extens import { IInterpreterService } from './interpreter/contracts'; import { IServiceContainer, IServiceManager } from './ioc/types'; import { JupyterExtensionIntegration } from './jupyter/jupyterIntegration'; -import { IDataViewerDataProvider, IJupyterUriProvider } from './jupyter/types'; import { traceError } from './logging'; import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; import { buildEnvironmentApi } from './environmentApi'; @@ -37,6 +35,13 @@ export function buildApi( const outputChannel = serviceContainer.get(ILanguageServerOutputChannel); const api: PythonExtension & { + /** + * Internal API just for Jupyter, hence don't include in the official types. + */ + jupyter: { + registerHooks(): void; + }; + } & { /** * @deprecated Temporarily exposed for Pylance until we expose this API generally. Will be removed in an * iteration or two. @@ -111,16 +116,6 @@ export function buildApi( return { execCommand: pythonPath === '' ? undefined : [pythonPath] }; }, }, - // These are for backwards compatibility. Other extensions are using these APIs and we don't want - // to force them to move to the jupyter extension ... yet. - datascience: { - registerRemoteServerProvider: jupyterIntegration - ? jupyterIntegration.registerRemoteServerProvider.bind(jupyterIntegration) - : ((noop as unknown) as (serverProvider: IJupyterUriProvider) => void), - showDataViewer: jupyterIntegration - ? jupyterIntegration.showDataViewer.bind(jupyterIntegration) - : ((noop as unknown) as (dataProvider: IDataViewerDataProvider, title: string) => Promise), - }, pylance: { createClient: (...args: any[]): BaseLanguageClient => { // Make sure we share output channel so that we can share one with diff --git a/extensions/positron-python/src/client/api/types.ts b/extensions/positron-python/src/client/api/types.ts index 4e13ec4853e..4de554bf5a2 100644 --- a/extensions/positron-python/src/client/api/types.ts +++ b/extensions/positron-python/src/client/api/types.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { CancellationToken, Event, Uri, WorkspaceFolder, QuickPickItem } from 'vscode'; +import { CancellationToken, Event, Uri, WorkspaceFolder, extensions } from 'vscode'; /* * Do not introduce any breaking changes to this API. @@ -10,21 +10,16 @@ import { CancellationToken, Event, Uri, WorkspaceFolder, QuickPickItem } from 'v export interface PythonExtension { /** * Promise indicating whether all parts of the extension have completed loading or not. - * @type {Promise} */ ready: Promise; - 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} + * @param host + * @param port + * @param waitUntilDebuggerAttaches Defaults to `true`. */ getRemoteLauncherCommand(host: string, port: number, waitUntilDebuggerAttaches: boolean): Promise; @@ -35,20 +30,6 @@ export interface PythonExtension { getDebuggerPackagePath(): Promise; }; - 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; - /** - * 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 @@ -125,47 +106,6 @@ export interface PythonExtension { }; } -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; - getServerUri(handle: JupyterServerUriHandle): Promise; -} - -interface IDataFrameInfo { - columns?: { key: string; type: ColumnType }[]; - indexColumn?: string; - rowCount?: number; -} - -export interface IDataViewerDataProvider { - dispose(): void; - getDataFrameInfo(): Promise; - getAllRows(): Promise; - getRows(start: number, end: number): Promise; -} - -enum ColumnType { - String = 'string', - Number = 'number', - Bool = 'bool', -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type IRowsResponse = any[]; - export type RefreshOptions = { /** * When `true`, force trigger a refresh regardless of whether a refresh was already triggered. Note this can be expensive so @@ -387,3 +327,23 @@ export type EnvironmentVariablesChangeEvent = { */ readonly env: EnvironmentVariables; }; + +export const PVSC_EXTENSION_ID = 'ms-python.python'; + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace PythonExtension { + /** + * Returns the API exposed by the Python extension in VS Code. + */ + export async function api(): Promise { + const extension = extensions.getExtension(PVSC_EXTENSION_ID); + if (extension === undefined) { + throw new Error(`Python extension is not installed or is disabled`); + } + if (!extension.isActive) { + await extension.activate(); + } + const pythonApi: PythonExtension = extension.exports; + return pythonApi; + } +} diff --git a/extensions/positron-python/src/client/common/constants.ts b/extensions/positron-python/src/client/common/constants.ts index d4de9163886..9239e5fec4b 100644 --- a/extensions/positron-python/src/client/common/constants.ts +++ b/extensions/positron-python/src/client/common/constants.ts @@ -44,6 +44,7 @@ export namespace Commands { export const Enable_SourceMap_Support = 'python.enableSourceMapSupport'; export const Exec_In_Terminal = 'python.execInTerminal'; export const Exec_In_Terminal_Icon = 'python.execInTerminal-icon'; + export const Exec_In_Separate_Terminal = 'python.execInDedicatedTerminal'; // --- Start Positron --- export const Exec_In_Console = 'python.execInConsole'; // --- End Positron --- diff --git a/extensions/positron-python/src/client/common/experiments/helpers.ts b/extensions/positron-python/src/client/common/experiments/helpers.ts index 04da948fd15..bae96b222eb 100644 --- a/extensions/positron-python/src/client/common/experiments/helpers.ts +++ b/extensions/positron-python/src/client/common/experiments/helpers.ts @@ -3,10 +3,16 @@ 'use strict'; +import { env, workspace } from 'vscode'; import { IExperimentService } from '../types'; import { TerminalEnvVarActivation } from './groups'; +import { isTestExecution } from '../constants'; export function inTerminalEnvVarExperiment(experimentService: IExperimentService): boolean { + if (!isTestExecution() && workspace.workspaceFile && env.remoteName) { + // TODO: Remove this if statement once https://github.com/microsoft/vscode/issues/180486 is fixed. + return false; + } if (!experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)) { return false; } diff --git a/extensions/positron-python/src/client/common/installer/condaInstaller.ts b/extensions/positron-python/src/client/common/installer/condaInstaller.ts index a20b35e0f11..40ea22f8102 100644 --- a/extensions/positron-python/src/client/common/installer/condaInstaller.ts +++ b/extensions/positron-python/src/client/common/installer/condaInstaller.ts @@ -88,18 +88,7 @@ export class CondaInstaller extends ModuleInstaller { // Found that using conda-forge is best at packages like tensorboard & ipykernel which seem to get updated first on conda-forge // https://github.com/microsoft/vscode-jupyter/issues/7787 & https://github.com/microsoft/vscode-python/issues/17628 // Do this just for the datascience packages. - if ( - [ - Product.tensorboard, - Product.ipykernel, - Product.pandas, - Product.nbconvert, - Product.jupyter, - Product.notebook, - ] - .map(translateProductToModule) - .includes(moduleName) - ) { + if ([Product.tensorboard, Product.ipykernel].map(translateProductToModule).includes(moduleName)) { args.push('-c', 'conda-forge'); } if (info && info.name) { diff --git a/extensions/positron-python/src/client/common/installer/moduleInstaller.ts b/extensions/positron-python/src/client/common/installer/moduleInstaller.ts index 662d333e2f9..0268b26ae7e 100644 --- a/extensions/positron-python/src/client/common/installer/moduleInstaller.ts +++ b/extensions/positron-python/src/client/common/installer/moduleInstaller.ts @@ -269,18 +269,8 @@ export function translateProductToModule(product: Product): string { return 'unittest'; case Product.bandit: return 'bandit'; - case Product.jupyter: - return 'jupyter'; - case Product.notebook: - return 'notebook'; - case Product.pandas: - return 'pandas'; case Product.ipykernel: return 'ipykernel'; - case Product.nbconvert: - return 'nbconvert'; - case Product.kernelspec: - return 'kernelspec'; case Product.tensorboard: return 'tensorboard'; case Product.torchProfilerInstallName: diff --git a/extensions/positron-python/src/client/common/installer/productNames.ts b/extensions/positron-python/src/client/common/installer/productNames.ts index 6474e8a2a51..f91a815f7c3 100644 --- a/extensions/positron-python/src/client/common/installer/productNames.ts +++ b/extensions/positron-python/src/client/common/installer/productNames.ts @@ -19,11 +19,6 @@ ProductNames.set(Product.yapf, 'yapf'); ProductNames.set(Product.tensorboard, 'tensorboard'); ProductNames.set(Product.torchProfilerInstallName, 'torch-tb-profiler'); ProductNames.set(Product.torchProfilerImportName, 'torch_tb_profiler'); -ProductNames.set(Product.jupyter, 'jupyter'); -ProductNames.set(Product.notebook, 'notebook'); ProductNames.set(Product.ipykernel, 'ipykernel'); -ProductNames.set(Product.nbconvert, 'nbconvert'); -ProductNames.set(Product.kernelspec, 'kernelspec'); -ProductNames.set(Product.pandas, 'pandas'); ProductNames.set(Product.pip, 'pip'); ProductNames.set(Product.ensurepip, 'ensurepip'); diff --git a/extensions/positron-python/src/client/common/installer/productService.ts b/extensions/positron-python/src/client/common/installer/productService.ts index 5de130e84d0..b47ff49a691 100644 --- a/extensions/positron-python/src/client/common/installer/productService.ts +++ b/extensions/positron-python/src/client/common/installer/productService.ts @@ -25,12 +25,7 @@ export class ProductService implements IProductService { this.ProductTypes.set(Product.autopep8, ProductType.Formatter); this.ProductTypes.set(Product.black, ProductType.Formatter); this.ProductTypes.set(Product.yapf, ProductType.Formatter); - this.ProductTypes.set(Product.jupyter, ProductType.DataScience); - this.ProductTypes.set(Product.notebook, ProductType.DataScience); this.ProductTypes.set(Product.ipykernel, ProductType.DataScience); - this.ProductTypes.set(Product.nbconvert, ProductType.DataScience); - this.ProductTypes.set(Product.kernelspec, ProductType.DataScience); - this.ProductTypes.set(Product.pandas, ProductType.DataScience); this.ProductTypes.set(Product.tensorboard, ProductType.DataScience); this.ProductTypes.set(Product.torchProfilerInstallName, ProductType.DataScience); this.ProductTypes.set(Product.torchProfilerImportName, ProductType.DataScience); diff --git a/extensions/positron-python/src/client/common/interpreterPathService.ts b/extensions/positron-python/src/client/common/interpreterPathService.ts index 9eea1548977..8af14296256 100644 --- a/extensions/positron-python/src/client/common/interpreterPathService.ts +++ b/extensions/positron-python/src/client/common/interpreterPathService.ts @@ -30,7 +30,7 @@ export const isRemoteGlobalSettingCopiedKey = 'isRemoteGlobalSettingCopiedKey'; export const defaultInterpreterPathSetting: keyof IPythonSettings = 'defaultInterpreterPath'; const CI_PYTHON_PATH = getCIPythonPath(); -function getCIPythonPath(): string { +export function getCIPythonPath(): string { if (process.env.CI_PYTHON_PATH && fs.existsSync(process.env.CI_PYTHON_PATH)) { return process.env.CI_PYTHON_PATH; } diff --git a/extensions/positron-python/src/client/common/persistentState.ts b/extensions/positron-python/src/client/common/persistentState.ts index 48e885a676a..76f6d2112fe 100644 --- a/extensions/positron-python/src/client/common/persistentState.ts +++ b/extensions/positron-python/src/client/common/persistentState.ts @@ -173,7 +173,7 @@ export interface IPersistentStorage { */ export function getGlobalStorage(context: IExtensionContext, key: string, defaultValue?: T): IPersistentStorage { const globalKeysStorage = new PersistentState(context.globalState, GLOBAL_PERSISTENT_KEYS, []); - const found = globalKeysStorage.value.find((value) => value.key === key && value.defaultValue === defaultValue); + const found = globalKeysStorage.value.find((value) => value.key === key); if (!found) { const newValue = [{ key, defaultValue }, ...globalKeysStorage.value]; globalKeysStorage.updateValue(newValue).ignoreErrors(); diff --git a/extensions/positron-python/src/client/common/process/processFactory.ts b/extensions/positron-python/src/client/common/process/processFactory.ts index 8681d5073d8..40204a640da 100644 --- a/extensions/positron-python/src/client/common/process/processFactory.ts +++ b/extensions/positron-python/src/client/common/process/processFactory.ts @@ -17,8 +17,10 @@ export class ProcessServiceFactory implements IProcessServiceFactory { @inject(IProcessLogger) private readonly processLogger: IProcessLogger, @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry, ) {} - public async create(resource?: Uri): Promise { - const customEnvVars = await this.envVarsService.getEnvironmentVariables(resource); + public async create(resource?: Uri, options?: { doNotUseCustomEnvs: boolean }): Promise { + const customEnvVars = options?.doNotUseCustomEnvs + ? undefined + : await this.envVarsService.getEnvironmentVariables(resource); const proc: IProcessService = new ProcessService(customEnvVars); this.disposableRegistry.push(proc); return proc.on('exec', this.processLogger.logProcess.bind(this.processLogger)); diff --git a/extensions/positron-python/src/client/common/process/rawProcessApis.ts b/extensions/positron-python/src/client/common/process/rawProcessApis.ts index 025e5b60722..6f3e40d6873 100644 --- a/extensions/positron-python/src/client/common/process/rawProcessApis.ts +++ b/extensions/positron-python/src/client/common/process/rawProcessApis.ts @@ -100,7 +100,7 @@ export function plainExec( const deferred = createDeferred>(); const disposable: IDisposable = { dispose: () => { - if (!proc.killed && !deferred.completed) { + if (!proc.killed) { proc.kill(); } }, @@ -156,10 +156,12 @@ export function plainExec( deferred.resolve({ stdout, stderr }); } internalDisposables.forEach((d) => d.dispose()); + disposable.dispose(); }); proc.once('error', (ex) => { deferred.reject(ex); internalDisposables.forEach((d) => d.dispose()); + disposable.dispose(); }); return deferred.promise; diff --git a/extensions/positron-python/src/client/common/process/types.ts b/extensions/positron-python/src/client/common/process/types.ts index 62e787b694b..d4b742718e3 100644 --- a/extensions/positron-python/src/client/common/process/types.ts +++ b/extensions/positron-python/src/client/common/process/types.ts @@ -55,7 +55,7 @@ export interface IProcessService extends IDisposable { export const IProcessServiceFactory = Symbol('IProcessServiceFactory'); export interface IProcessServiceFactory { - create(resource?: Uri): Promise; + create(resource?: Uri, options?: { doNotUseCustomEnvs: boolean }): Promise; } export const IPythonExecutionFactory = Symbol('IPythonExecutionFactory'); diff --git a/extensions/positron-python/src/client/common/terminal/factory.ts b/extensions/positron-python/src/client/common/terminal/factory.ts index 3855cb6cee3..39cc88c4b02 100644 --- a/extensions/positron-python/src/client/common/terminal/factory.ts +++ b/extensions/positron-python/src/client/common/terminal/factory.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'inversify'; import { Uri } from 'vscode'; +import * as path from 'path'; import { IInterpreterService } from '../../interpreter/contracts'; import { IServiceContainer } from '../../ioc/types'; import { PythonEnvironment } from '../../pythonEnvironments/info'; @@ -23,13 +24,17 @@ export class TerminalServiceFactory implements ITerminalServiceFactory { ) { this.terminalServices = new Map(); } - public getTerminalService(options: TerminalCreationOptions): ITerminalService { + public getTerminalService(options: TerminalCreationOptions & { newTerminalPerFile?: boolean }): ITerminalService { const resource = options?.resource; const title = options?.title; - const terminalTitle = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python'; + let terminalTitle = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python'; const interpreter = options?.interpreter; - const id = this.getTerminalId(terminalTitle, resource, interpreter); + const id = this.getTerminalId(terminalTitle, resource, interpreter, options.newTerminalPerFile); if (!this.terminalServices.has(id)) { + if (resource && options.newTerminalPerFile) { + terminalTitle = `${terminalTitle}: ${path.basename(resource.fsPath).replace('.py', '')}`; + } + options.title = terminalTitle; const terminalService = new TerminalService(this.serviceContainer, options); this.terminalServices.set(id, terminalService); } @@ -46,13 +51,19 @@ export class TerminalServiceFactory implements ITerminalServiceFactory { title = typeof title === 'string' && title.trim().length > 0 ? title.trim() : 'Python'; return new TerminalService(this.serviceContainer, { resource, title }); } - private getTerminalId(title: string, resource?: Uri, interpreter?: PythonEnvironment): string { + private getTerminalId( + title: string, + resource?: Uri, + interpreter?: PythonEnvironment, + newTerminalPerFile?: boolean, + ): string { if (!resource && !interpreter) { return title; } const workspaceFolder = this.serviceContainer .get(IWorkspaceService) .getWorkspaceFolder(resource || undefined); - return `${title}:${workspaceFolder?.uri.fsPath || ''}:${interpreter?.path}`; + const fileId = resource && newTerminalPerFile ? resource.fsPath : ''; + return `${title}:${workspaceFolder?.uri.fsPath || ''}:${interpreter?.path}:${fileId}`; } } diff --git a/extensions/positron-python/src/client/common/terminal/types.ts b/extensions/positron-python/src/client/common/terminal/types.ts index 880bf0dd72f..30318868237 100644 --- a/extensions/positron-python/src/client/common/terminal/types.ts +++ b/extensions/positron-python/src/client/common/terminal/types.ts @@ -97,7 +97,7 @@ export interface ITerminalServiceFactory { * @returns {ITerminalService} * @memberof ITerminalServiceFactory */ - getTerminalService(options: TerminalCreationOptions): ITerminalService; + getTerminalService(options: TerminalCreationOptions & { newTerminalPerFile?: boolean }): ITerminalService; createTerminalService(resource?: Uri, title?: string): ITerminalService; } diff --git a/extensions/positron-python/src/client/common/types.ts b/extensions/positron-python/src/client/common/types.ts index 36b0c150395..ec72c5f4b57 100644 --- a/extensions/positron-python/src/client/common/types.ts +++ b/extensions/positron-python/src/client/common/types.ts @@ -110,12 +110,7 @@ export enum Product { isort = 15, black = 16, bandit = 17, - jupyter = 18, ipykernel = 19, - notebook = 20, - kernelspec = 21, - nbconvert = 22, - pandas = 23, tensorboard = 24, torchProfilerInstallName = 25, torchProfilerImportName = 26, diff --git a/extensions/positron-python/src/client/common/utils/async.ts b/extensions/positron-python/src/client/common/utils/async.ts index 29bf4a8d6fc..5905399cd4a 100644 --- a/extensions/positron-python/src/client/common/utils/async.ts +++ b/extensions/positron-python/src/client/common/utils/async.ts @@ -50,11 +50,17 @@ class DeferredImpl implements Deferred { } public resolve(_value: T | PromiseLike) { + if (this.completed) { + return; + } this._resolve.apply(this.scope ? this.scope : this, [_value]); this._resolved = true; } public reject(_reason?: string | Error | Record) { + if (this.completed) { + return; + } this._reject.apply(this.scope ? this.scope : this, [_reason]); this._rejected = true; } diff --git a/extensions/positron-python/src/client/common/utils/localize.ts b/extensions/positron-python/src/client/common/utils/localize.ts index b3af0c47695..4cda15e15ec 100644 --- a/extensions/positron-python/src/client/common/utils/localize.ts +++ b/extensions/positron-python/src/client/common/utils/localize.ts @@ -63,7 +63,7 @@ export namespace Common { export const openOutputPanel = l10n.t('Show output'); export const noIWillDoItLater = l10n.t('No, I will do it later'); export const notNow = l10n.t('Not now'); - export const doNotShowAgain = l10n.t('Do not show again'); + export const doNotShowAgain = l10n.t("Don't show again"); export const reload = l10n.t('Reload'); export const moreInfo = l10n.t('More Info'); export const learnMore = l10n.t('Learn more'); @@ -198,6 +198,9 @@ export namespace Interpreters { ); export const activatingTerminals = l10n.t('Reactivating terminals...'); export const activateTerminalDescription = l10n.t('Activated environment for'); + export const terminalEnvVarCollectionPrompt = l10n.t( + 'The Python extension automatically activates all terminals using the selected environment, even when the name of the environment{0} is not present in the terminal prompt. [Learn more](https://aka.ms/vscodePythonTerminalActivation).', + ); export const activatedCondaEnvLaunch = l10n.t( 'We noticed VS Code was launched from an activated conda environment, would you like to select it?', ); @@ -461,6 +464,15 @@ export namespace CreateEnv { export const error = l10n.t('Creating virtual environment failed with error.'); export const tomlExtrasQuickPickTitle = l10n.t('Select optional dependencies to install from pyproject.toml'); export const requirementsQuickPickTitle = l10n.t('Select dependencies to install'); + export const recreate = l10n.t('Recreate'); + export const recreateDescription = l10n.t('Delete existing ".venv" environment and create a new one'); + export const useExisting = l10n.t('Use Existing'); + export const useExistingDescription = l10n.t('Use existing ".venv" environment with no changes to it'); + export const existingVenvQuickPickPlaceholder = l10n.t( + 'Choose an option to handle the existing ".venv" environment', + ); + export const deletingEnvironmentProgress = l10n.t('Deleting existing ".venv" environment...'); + export const errorDeletingEnvironment = l10n.t('Error while deleting existing ".venv" environment.'); } export namespace Conda { diff --git a/extensions/positron-python/src/client/common/utils/misc.ts b/extensions/positron-python/src/client/common/utils/misc.ts index c95a3cc7557..455392d28eb 100644 --- a/extensions/positron-python/src/client/common/utils/misc.ts +++ b/extensions/positron-python/src/client/common/utils/misc.ts @@ -60,7 +60,7 @@ function isUri(resource?: Uri | any): resource is Uri { /** * Create a filter func that determine if the given URI and candidate match. * - * The scheme must match, as well as path. + * Only compares path. * * @param checkParent - if `true`, match if the candidate is rooted under `uri` * or if the candidate matches `uri` exactly. @@ -80,9 +80,8 @@ export function getURIFilter( } const uriRoot = `${uriPath}/`; function filter(candidate: Uri): boolean { - if (candidate.scheme !== uri.scheme) { - return false; - } + // Do not compare schemes as it is sometimes not available, in + // which case file is assumed as scheme. let candidatePath = candidate.path; while (candidatePath.endsWith('/')) { candidatePath = candidatePath.slice(0, -1); diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts index f48b2c19aaf..c4ae6a204d7 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/launch.ts @@ -19,7 +19,7 @@ import { getProgram, IDebugEnvironmentVariablesService } from './helper'; @injectable() export class LaunchConfigurationResolver extends BaseConfigurationResolver { - private isPythonSet = false; + private isCustomPythonSet = false; constructor( @inject(IDiagnosticsService) @@ -38,7 +38,7 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver { - this.isPythonSet = debugConfiguration.python !== undefined; + this.isCustomPythonSet = debugConfiguration.python !== undefined; if ( debugConfiguration.name === undefined && debugConfiguration.type === undefined && @@ -55,6 +55,10 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver(CACHE_DURATION); + const memCache = new InMemoryCache(CACHE_DURATION); return this.getActivatedEnvironmentVariablesImpl(resource, interpreter, allowExceptions, shell) .then((vars) => { - cache.data = vars; - this.activatedEnvVariablesCache.set(cacheKey, cache); + memCache.data = vars; + this.activatedEnvVariablesCache.set(cacheKey, memCache); sendTelemetryEvent( EventName.PYTHON_INTERPRETER_ACTIVATION_ENVIRONMENT_VARIABLES, stopWatch.elapsedTime, @@ -176,6 +177,35 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi }); } + @cache(-1, true) + public async getProcessEnvironmentVariables(resource: Resource, shell?: string): Promise { + // Try to get the process environment variables using Python by printing variables, that can be little different + // from `process.env` and is preferred when calculating diff. + const globalInterpreters = this.interpreterService + .getInterpreters() + .filter((i) => !virtualEnvTypes.includes(i.envType)); + const interpreterPath = + globalInterpreters.length > 0 && globalInterpreters[0] ? globalInterpreters[0].path : 'python'; + try { + const [args, parse] = internalScripts.printEnvVariables(); + args.forEach((arg, i) => { + args[i] = arg.toCommandArgumentForPythonExt(); + }); + const command = `${interpreterPath} ${args.join(' ')}`; + const processService = await this.processServiceFactory.create(resource, { doNotUseCustomEnvs: true }); + const result = await processService.shellExec(command, { + shell, + timeout: ENVIRONMENT_TIMEOUT, + maxBuffer: 1000 * 1000, + throwOnStdErr: false, + }); + const returnedEnv = this.parseEnvironmentOutput(result.stdout, parse); + return returnedEnv ?? process.env; + } catch (ex) { + return process.env; + } + } + public async getEnvironmentActivationShellCommands( resource: Resource, interpreter?: PythonEnvironment, @@ -231,7 +261,7 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi ); traceVerbose(`Activation Commands received ${activationCommands} for shell ${shellInfo.shell}`); if (!activationCommands || !Array.isArray(activationCommands) || activationCommands.length === 0) { - if (interpreter?.envType === EnvironmentType.Venv) { + if (interpreter && [EnvironmentType.Venv, EnvironmentType.Pyenv].includes(interpreter?.envType)) { const key = getSearchPathEnvVarNames()[0]; if (env[key]) { env[key] = `${path.dirname(interpreter.path)}${path.delimiter}${env[key]}`; @@ -247,7 +277,14 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi const activationCommand = fixActivationCommands(activationCommands).join(' && '); // In order to make sure we know where the environment output is, // put in a dummy echo we can look for - command = `${activationCommand} && echo '${ENVIRONMENT_PREFIX}' && python ${args.join(' ')}`; + const commandSeparator = [TerminalShellType.powershell, TerminalShellType.powershellCore].includes( + shellInfo.shellType, + ) + ? ';' + : '&&'; + command = `${activationCommand} ${commandSeparator} echo '${ENVIRONMENT_PREFIX}' ${commandSeparator} python ${args.join( + ' ', + )}`; } // Make sure python warnings don't interfere with getting the environment. However diff --git a/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts new file mode 100644 index 00000000000..c8aea205a32 --- /dev/null +++ b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionPrompt.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import { Uri, l10n } from 'vscode'; +import * as path from 'path'; +import { IActiveResourceService, IApplicationShell, ITerminalManager } from '../../common/application/types'; +import { + IConfigurationService, + IDisposableRegistry, + IExperimentService, + IPersistentStateFactory, + Resource, +} from '../../common/types'; +import { Common, Interpreters } from '../../common/utils/localize'; +import { IExtensionSingleActivationService } from '../../activation/types'; +import { ITerminalEnvVarCollectionService } from './types'; +import { inTerminalEnvVarExperiment } from '../../common/experiments/helpers'; +import { IInterpreterService } from '../contracts'; +import { PythonEnvironment } from '../../pythonEnvironments/info'; + +export const terminalEnvCollectionPromptKey = 'TERMINAL_ENV_COLLECTION_PROMPT_KEY'; + +@injectable() +export class TerminalEnvVarCollectionPrompt implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false }; + + constructor( + @inject(IApplicationShell) private readonly appShell: IApplicationShell, + @inject(IPersistentStateFactory) private readonly persistentStateFactory: IPersistentStateFactory, + @inject(ITerminalManager) private readonly terminalManager: ITerminalManager, + @inject(IDisposableRegistry) private readonly disposableRegistry: IDisposableRegistry, + @inject(IActiveResourceService) private readonly activeResourceService: IActiveResourceService, + @inject(ITerminalEnvVarCollectionService) + private readonly terminalEnvVarCollectionService: ITerminalEnvVarCollectionService, + @inject(IConfigurationService) private readonly configurationService: IConfigurationService, + @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, + @inject(IExperimentService) private readonly experimentService: IExperimentService, + ) {} + + public async activate(): Promise { + if (!inTerminalEnvVarExperiment(this.experimentService)) { + return; + } + this.disposableRegistry.push( + this.terminalManager.onDidOpenTerminal(async (terminal) => { + const cwd = + 'cwd' in terminal.creationOptions && terminal.creationOptions.cwd + ? terminal.creationOptions.cwd + : this.activeResourceService.getActiveResource(); + const resource = typeof cwd === 'string' ? Uri.file(cwd) : cwd; + const settings = this.configurationService.getSettings(resource); + if (!settings.terminal.activateEnvironment) { + return; + } + if (this.terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)) { + // No need to show notification if terminal prompt already indicates when env is activated. + return; + } + await this.notifyUsers(resource); + }), + ); + } + + private async notifyUsers(resource: Resource): Promise { + const notificationPromptEnabled = this.persistentStateFactory.createGlobalPersistentState( + terminalEnvCollectionPromptKey, + true, + ); + if (!notificationPromptEnabled.value) { + return; + } + const prompts = [Common.doNotShowAgain]; + const interpreter = await this.interpreterService.getActiveInterpreter(resource); + const terminalPromptName = getPromptName(interpreter); + const selection = await this.appShell.showInformationMessage( + Interpreters.terminalEnvVarCollectionPrompt.format(terminalPromptName), + ...prompts, + ); + if (!selection) { + return; + } + if (selection === prompts[0]) { + await notificationPromptEnabled.updateValue(false); + } + } +} + +function getPromptName(interpreter?: PythonEnvironment) { + if (!interpreter) { + return ''; + } + if (interpreter.envName) { + return `, ${l10n.t('i.e')} "(${interpreter.envName})"`; + } + if (interpreter.envPath) { + return `, ${l10n.t('i.e')} "(${path.basename(interpreter.envPath)})"`; + } + return ''; +} diff --git a/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts index 85b39342583..9015dd7b938 100644 --- a/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts +++ b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts @@ -8,7 +8,7 @@ import { ProgressLocation, MarkdownString, WorkspaceFolder, - EnvironmentVariableCollection, + GlobalEnvironmentVariableCollection, EnvironmentVariableScope, } from 'vscode'; import { pathExists } from 'fs-extra'; @@ -27,25 +27,40 @@ import { } from '../../common/types'; import { Deferred, createDeferred } from '../../common/utils/async'; import { Interpreters } from '../../common/utils/localize'; -import { traceDecoratorVerbose, traceVerbose } from '../../logging'; +import { traceDecoratorVerbose, traceError, traceVerbose, traceWarn } from '../../logging'; import { IInterpreterService } from '../contracts'; import { defaultShells } from './service'; -import { IEnvironmentActivationService } from './types'; -import { EnvironmentType } from '../../pythonEnvironments/info'; +import { IEnvironmentActivationService, ITerminalEnvVarCollectionService } from './types'; +import { EnvironmentType, PythonEnvironment } from '../../pythonEnvironments/info'; import { getSearchPathEnvVarNames } from '../../common/utils/exec'; +import { EnvironmentVariables } from '../../common/variables/types'; +import { TerminalShellType } from '../../common/terminal/types'; +import { OSType } from '../../common/utils/platform'; @injectable() -export class TerminalEnvVarCollectionService implements IExtensionActivationService { +export class TerminalEnvVarCollectionService implements IExtensionActivationService, ITerminalEnvVarCollectionService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false, }; + /** + * Prompts for these shells cannot be set reliably using variables + */ + private noPromptVariableShells = [ + TerminalShellType.powershell, + TerminalShellType.powershellCore, + TerminalShellType.fish, + ]; + private deferred: Deferred | undefined; private registeredOnce = false; - private previousEnvVars = _normCaseKeys(process.env); + /** + * Carries default environment variables for the currently selected shell. + */ + private processEnvVars: EnvironmentVariables | undefined; constructor( @inject(IPlatformService) private readonly platform: IPlatformService, @@ -62,50 +77,58 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ ) {} public async activate(resource: Resource): Promise { - if (!inTerminalEnvVarExperiment(this.experimentService)) { - this.context.environmentVariableCollection.clear(); - await this.handleMicroVenv(resource); + try { + if (!inTerminalEnvVarExperiment(this.experimentService)) { + this.context.environmentVariableCollection.clear(); + await this.handleMicroVenv(resource); + if (!this.registeredOnce) { + this.interpreterService.onDidChangeInterpreter( + async (r) => { + await this.handleMicroVenv(r); + }, + this, + this.disposables, + ); + this.registeredOnce = true; + } + return; + } if (!this.registeredOnce) { this.interpreterService.onDidChangeInterpreter( async (r) => { - await this.handleMicroVenv(r); + this.showProgress(); + await this._applyCollection(r).ignoreErrors(); + this.hideProgress(); + }, + this, + this.disposables, + ); + this.applicationEnvironment.onDidChangeShell( + async (shell: string) => { + this.showProgress(); + this.processEnvVars = undefined; + // Pass in the shell where known instead of relying on the application environment, because of bug + // on VSCode: https://github.com/microsoft/vscode/issues/160694 + await this._applyCollection(undefined, shell).ignoreErrors(); + this.hideProgress(); }, this, this.disposables, ); this.registeredOnce = true; } - return; - } - if (!this.registeredOnce) { - this.interpreterService.onDidChangeInterpreter( - async (r) => { - this.showProgress(); - await this._applyCollection(r).ignoreErrors(); - this.hideProgress(); - }, - this, - this.disposables, - ); - this.applicationEnvironment.onDidChangeShell( - async (shell: string) => { - this.showProgress(); - // Pass in the shell where known instead of relying on the application environment, because of bug - // on VSCode: https://github.com/microsoft/vscode/issues/160694 - await this._applyCollection(undefined, shell).ignoreErrors(); - this.hideProgress(); - }, - this, - this.disposables, - ); - this.registeredOnce = true; + this._applyCollection(resource).ignoreErrors(); + } catch (ex) { + traceError(`Activating terminal env collection failed`, ex); } - this._applyCollection(resource).ignoreErrors(); } public async _applyCollection(resource: Resource, shell = this.applicationEnvironment.shell): Promise { const workspaceFolder = this.getWorkspaceFolder(resource); const settings = this.configurationService.getSettings(resource); + const envVarCollection = this.getEnvironmentVariableCollection({ workspaceFolder }); + // Clear any previously set env vars from collection + envVarCollection.clear(); if (!settings.terminal.activateEnvironment) { traceVerbose('Activating environments in terminal is disabled for', resource?.fsPath); return; @@ -116,7 +139,6 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ undefined, shell, ); - const envVarCollection = this.getEnvironmentVariableCollection(workspaceFolder); if (!env) { const shellType = identifyShellFromShellPath(shell); const defaultShell = defaultShells[this.platform.osType]; @@ -126,63 +148,173 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ await this._applyCollection(resource, defaultShell?.shell); return; } - envVarCollection.clear(); - this.previousEnvVars = _normCaseKeys(process.env); + await this.trackTerminalPrompt(shell, resource, env); + this.processEnvVars = undefined; return; } - const previousEnv = this.previousEnvVars; - this.previousEnvVars = env; + if (!this.processEnvVars) { + this.processEnvVars = await this.environmentActivationService.getProcessEnvironmentVariables( + resource, + shell, + ); + } + const processEnv = this.processEnvVars; + + // PS1 in some cases is a shell variable (not an env variable) so "env" might not contain it, calculate it in that case. + env.PS1 = await this.getPS1(shell, resource, env); + Object.keys(env).forEach((key) => { - const value = env[key]; - const prevValue = previousEnv[key]; + if (shouldSkip(key)) { + return; + } + let value = env[key]; + const prevValue = processEnv[key]; if (prevValue !== value) { if (value !== undefined) { + if (key === 'PS1') { + // We cannot have the full PS1 without executing in terminal, which we do not. Hence prepend it. + traceVerbose(`Prepending environment variable ${key} in collection with ${value}`); + envVarCollection.prepend(key, value, { + applyAtShellIntegration: true, + applyAtProcessCreation: false, + }); + return; + } + if (key === 'PATH') { + if (processEnv.PATH && env.PATH?.endsWith(processEnv.PATH)) { + // Prefer prepending to PATH instead of replacing it, as we do not want to replace any + // changes to PATH users might have made it in their init scripts (~/.bashrc etc.) + const prependedPart = env.PATH.slice(0, -processEnv.PATH.length); + value = prependedPart; + traceVerbose(`Prepending environment variable ${key} in collection with ${value}`); + envVarCollection.prepend(key, value, { + applyAtShellIntegration: true, + applyAtProcessCreation: true, + }); + } else { + traceVerbose(`Prepending environment variable ${key} in collection to ${value}`); + envVarCollection.prepend(key, value, { + applyAtShellIntegration: true, + applyAtProcessCreation: true, + }); + } + return; + } traceVerbose(`Setting environment variable ${key} in collection to ${value}`); - envVarCollection.replace(key, value, { applyAtShellIntegration: true }); - } else { - traceVerbose(`Clearing environment variable ${key} from collection`); - envVarCollection.delete(key); + envVarCollection.replace(key, value, { + applyAtShellIntegration: true, + applyAtProcessCreation: true, + }); } } }); - Object.keys(previousEnv).forEach((key) => { - // If the previous env var is not in the current env, clear it from collection. - if (!(key in env)) { - traceVerbose(`Clearing environment variable ${key} from collection`); - envVarCollection.delete(key); - } - }); + const displayPath = this.pathUtils.getDisplayName(settings.pythonPath, workspaceFolder?.uri.fsPath); const description = new MarkdownString(`${Interpreters.activateTerminalDescription} \`${displayPath}\``); envVarCollection.description = description; - } - private getEnvironmentVariableCollection(workspaceFolder?: WorkspaceFolder) { - const envVarCollection = this.context.environmentVariableCollection as EnvironmentVariableCollection & { - getScopedEnvironmentVariableCollection(scope: EnvironmentVariableScope): EnvironmentVariableCollection; - }; - return workspaceFolder - ? envVarCollection.getScopedEnvironmentVariableCollection({ workspaceFolder }) - : envVarCollection; + await this.trackTerminalPrompt(shell, resource, env); } - private async handleMicroVenv(resource: Resource) { + private isPromptSet = new Map(); + + // eslint-disable-next-line class-methods-use-this + public isTerminalPromptSetCorrectly(resource?: Resource): boolean { const workspaceFolder = this.getWorkspaceFolder(resource); - const interpreter = await this.interpreterService.getActiveInterpreter(resource); - if (interpreter?.envType === EnvironmentType.Venv) { - const activatePath = path.join(path.dirname(interpreter.path), 'activate'); - if (!(await pathExists(activatePath))) { - const envVarCollection = this.getEnvironmentVariableCollection(workspaceFolder); - const pathVarName = getSearchPathEnvVarNames()[0]; - envVarCollection.replace( - 'PATH', - `${path.dirname(interpreter.path)}${path.delimiter}${process.env[pathVarName]}`, - { applyAtShellIntegration: true }, - ); + return !!this.isPromptSet.get(workspaceFolder?.index); + } + + /** + * Call this once we know terminal prompt is set correctly for terminal owned by this resource. + */ + private terminalPromptIsCorrect(resource: Resource) { + const key = this.getWorkspaceFolder(resource)?.index; + this.isPromptSet.set(key, true); + } + + private terminalPromptIsUnknown(resource: Resource) { + const key = this.getWorkspaceFolder(resource)?.index; + this.isPromptSet.delete(key); + } + + /** + * Tracks whether prompt for terminal was correctly set. + */ + private async trackTerminalPrompt(shell: string, resource: Resource, env: EnvironmentVariables | undefined) { + this.terminalPromptIsUnknown(resource); + if (!env) { + this.terminalPromptIsCorrect(resource); + return; + } + const customShellType = identifyShellFromShellPath(shell); + if (this.noPromptVariableShells.includes(customShellType)) { + return; + } + if (this.platform.osType !== OSType.Windows) { + // These shells are expected to set PS1 variable for terminal prompt for virtual/conda environments. + const interpreter = await this.interpreterService.getActiveInterpreter(resource); + const shouldPS1BeSet = interpreter?.type !== undefined; + if (shouldPS1BeSet && !env.PS1) { + // PS1 should be set but no PS1 was set. return; } + const config = this.workspaceService + .getConfiguration('terminal') + .get('integrated.shellIntegration.enabled'); + if (!config) { + traceVerbose('PS1 is not set when shell integration is disabled.'); + return; + } + } + this.terminalPromptIsCorrect(resource); + } + + private async getPS1(shell: string, resource: Resource, env: EnvironmentVariables) { + if (env.PS1) { + return env.PS1; + } + const customShellType = identifyShellFromShellPath(shell); + if (this.noPromptVariableShells.includes(customShellType)) { + return undefined; + } + if (this.platform.osType !== OSType.Windows) { + // These shells are expected to set PS1 variable for terminal prompt for virtual/conda environments. + const interpreter = await this.interpreterService.getActiveInterpreter(resource); + const shouldPS1BeSet = interpreter?.type !== undefined; + if (shouldPS1BeSet && !env.PS1) { + // PS1 should be set but no PS1 was set. + return getPromptForEnv(interpreter); + } } - this.context.environmentVariableCollection.clear(); + return undefined; + } + + private async handleMicroVenv(resource: Resource) { + try { + const workspaceFolder = this.getWorkspaceFolder(resource); + const interpreter = await this.interpreterService.getActiveInterpreter(resource); + if (interpreter?.envType === EnvironmentType.Venv) { + const activatePath = path.join(path.dirname(interpreter.path), 'activate'); + if (!(await pathExists(activatePath))) { + const envVarCollection = this.getEnvironmentVariableCollection({ workspaceFolder }); + const pathVarName = getSearchPathEnvVarNames()[0]; + envVarCollection.replace( + 'PATH', + `${path.dirname(interpreter.path)}${path.delimiter}${process.env[pathVarName]}`, + { applyAtShellIntegration: true, applyAtProcessCreation: true }, + ); + return; + } + this.getEnvironmentVariableCollection({ workspaceFolder }).clear(); + } + } catch (ex) { + traceWarn(`Microvenv failed as it is using proposed API which is constantly changing`, ex); + } + } + + private getEnvironmentVariableCollection(scope: EnvironmentVariableScope = {}) { + const envVarCollection = this.context.environmentVariableCollection as GlobalEnvironmentVariableCollection; + return envVarCollection.getScoped(scope); } private getWorkspaceFolder(resource: Resource): WorkspaceFolder | undefined { @@ -224,13 +356,23 @@ export class TerminalEnvVarCollectionService implements IExtensionActivationServ } } -export function _normCaseKeys(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const result: NodeJS.ProcessEnv = {}; - Object.keys(env).forEach((key) => { - // `os.environ` script used to get env vars normalizes keys to upper case: - // https://github.com/python/cpython/issues/101754 - // So convert `process.env` keys to upper case to match. - result[key.toUpperCase()] = env[key]; - }); - return result; +function shouldSkip(env: string) { + return ['_', 'SHLVL'].includes(env); +} + +function getPromptForEnv(interpreter: PythonEnvironment | undefined) { + if (!interpreter) { + return undefined; + } + if (interpreter.envName) { + if (interpreter.envName === 'base') { + // If conda base environment is selected, it can lead to "(base)" appearing twice if we return the env name. + return undefined; + } + return `(${interpreter.envName}) `; + } + if (interpreter.envPath) { + return `(${path.basename(interpreter.envPath)}) `; + } + return undefined; } diff --git a/extensions/positron-python/src/client/interpreter/activation/types.ts b/extensions/positron-python/src/client/interpreter/activation/types.ts index d8e4ae16dbc..2b364cbeb86 100644 --- a/extensions/positron-python/src/client/interpreter/activation/types.ts +++ b/extensions/positron-python/src/client/interpreter/activation/types.ts @@ -4,10 +4,12 @@ 'use strict'; import { Resource } from '../../common/types'; +import { EnvironmentVariables } from '../../common/variables/types'; import { PythonEnvironment } from '../../pythonEnvironments/info'; export const IEnvironmentActivationService = Symbol('IEnvironmentActivationService'); export interface IEnvironmentActivationService { + getProcessEnvironmentVariables(resource: Resource, shell?: string): Promise; getActivatedEnvironmentVariables( resource: Resource, interpreter?: PythonEnvironment, @@ -19,3 +21,11 @@ export interface IEnvironmentActivationService { interpreter?: PythonEnvironment, ): Promise; } + +export const ITerminalEnvVarCollectionService = Symbol('ITerminalEnvVarCollectionService'); +export interface ITerminalEnvVarCollectionService { + /** + * Returns true if we know with high certainity the terminal prompt is set correctly for a particular resource. + */ + isTerminalPromptSetCorrectly(resource?: Resource): boolean; +} diff --git a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/base.ts b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/base.ts index f051fd67fff..8d67a6b92bb 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/base.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/base.ts @@ -61,7 +61,7 @@ export abstract class BaseInterpreterSelectorCommand implements IExtensionSingle }, ]; } - if (!this.workspaceService.workspaceFile && workspaceFolders.length === 1) { + if (workspaceFolders.length === 1) { return [ { folderUri: workspaceFolders[0].uri, diff --git a/extensions/positron-python/src/client/interpreter/interpreterService.ts b/extensions/positron-python/src/client/interpreter/interpreterService.ts index 3cfb651977b..b595fc2365a 100644 --- a/extensions/positron-python/src/client/interpreter/interpreterService.ts +++ b/extensions/positron-python/src/client/interpreter/interpreterService.ts @@ -31,7 +31,7 @@ import { PythonEnvironmentsChangedEvent, } from './contracts'; import { traceError, traceLog } from '../logging'; -import { Commands, PYTHON_LANGUAGE } from '../common/constants'; +import { Commands, PVSC_EXTENSION_ID, PYTHON_LANGUAGE } from '../common/constants'; import { reportActiveInterpreterChanged } from '../environmentApi'; import { IPythonExecutionFactory } from '../common/process/types'; import { Interpreters } from '../common/utils/localize'; @@ -138,7 +138,12 @@ export class InterpreterService implements Disposable, IInterpreterService { return false; } const document = this.docManager.activeTextEditor?.document; - if (document?.fileName.endsWith('settings.json')) { + // Output channel for MS Python related extensions. These contain "ms-python" in their ID. + const pythonOutputChannelPattern = PVSC_EXTENSION_ID.split('.')[0]; + if ( + document?.fileName.endsWith('settings.json') || + document?.fileName.includes(pythonOutputChannelPattern) + ) { return false; } return document?.languageId !== PYTHON_LANGUAGE; diff --git a/extensions/positron-python/src/client/interpreter/serviceRegistry.ts b/extensions/positron-python/src/client/interpreter/serviceRegistry.ts index 04af15415b0..018e7abfdc4 100644 --- a/extensions/positron-python/src/client/interpreter/serviceRegistry.ts +++ b/extensions/positron-python/src/client/interpreter/serviceRegistry.ts @@ -6,8 +6,9 @@ import { IExtensionActivationService, IExtensionSingleActivationService } from '../activation/types'; import { IServiceManager } from '../ioc/types'; import { EnvironmentActivationService } from './activation/service'; +import { TerminalEnvVarCollectionPrompt } from './activation/terminalEnvVarCollectionPrompt'; import { TerminalEnvVarCollectionService } from './activation/terminalEnvVarCollectionService'; -import { IEnvironmentActivationService } from './activation/types'; +import { IEnvironmentActivationService, ITerminalEnvVarCollectionService } from './activation/types'; import { InterpreterAutoSelectionService } from './autoSelection/index'; import { InterpreterAutoSelectionProxyService } from './autoSelection/proxy'; import { IInterpreterAutoSelectionService, IInterpreterAutoSelectionProxyService } from './autoSelection/types'; @@ -109,8 +110,13 @@ export function registerTypes(serviceManager: IServiceManager): void { IEnvironmentActivationService, EnvironmentActivationService, ); - serviceManager.addSingleton( - IExtensionActivationService, + serviceManager.addSingleton( + ITerminalEnvVarCollectionService, TerminalEnvVarCollectionService, ); + serviceManager.addBinding(ITerminalEnvVarCollectionService, IExtensionActivationService); + serviceManager.addSingleton( + IExtensionSingleActivationService, + TerminalEnvVarCollectionPrompt, + ); } diff --git a/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts b/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts index a0fa0fedb63..dbfd1bdf568 100644 --- a/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts +++ b/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts @@ -6,91 +6,20 @@ import { inject, injectable, named } from 'inversify'; import { dirname } from 'path'; -import { CancellationToken, Event, Extension, Memento, Uri } from 'vscode'; +import { Extension, Memento, Uri } from 'vscode'; import type { SemVer } from 'semver'; import { IContextKeyManager, IWorkspaceService } from '../common/application/types'; import { JUPYTER_EXTENSION_ID, PYLANCE_EXTENSION_ID } from '../common/constants'; -import { InterpreterUri, ModuleInstallFlags } from '../common/installer/types'; -import { - GLOBAL_MEMENTO, - IExtensions, - IInstaller, - IMemento, - InstallerResponse, - Product, - ProductInstallStatus, - Resource, -} from '../common/types'; +import { GLOBAL_MEMENTO, IExtensions, IMemento, Resource } from '../common/types'; import { getDebugpyPackagePath } from '../debugger/extension/adapter/remoteLaunchers'; import { IEnvironmentActivationService } from '../interpreter/activation/types'; import { IInterpreterQuickPickItem, IInterpreterSelector } from '../interpreter/configuration/types'; -import { - IComponentAdapter, - ICondaService, - IInterpreterDisplay, - IInterpreterService, - IInterpreterStatusbarVisibilityFilter, - PythonEnvironmentsChangedEvent, -} from '../interpreter/contracts'; +import { ICondaService, IInterpreterDisplay, IInterpreterStatusbarVisibilityFilter } from '../interpreter/contracts'; import { PythonEnvironment } from '../pythonEnvironments/info'; -import { IDataViewerDataProvider, IJupyterUriProvider } from './types'; import { PylanceApi } from '../activation/node/pylanceApi'; import { ExtensionContextKey } from '../common/application/contextKeys'; -/** - * This allows Python extension to update Product enum without breaking Jupyter. - * I.e. we have a strict contract, else using numbers (in enums) is bound to break across products. - */ -enum JupyterProductToInstall { - jupyter = 'jupyter', - ipykernel = 'ipykernel', - notebook = 'notebook', - kernelspec = 'kernelspec', - nbconvert = 'nbconvert', - pandas = 'pandas', - pip = 'pip', -} - -const ProductMapping: { [key in JupyterProductToInstall]: Product } = { - [JupyterProductToInstall.ipykernel]: Product.ipykernel, - [JupyterProductToInstall.jupyter]: Product.jupyter, - [JupyterProductToInstall.kernelspec]: Product.kernelspec, - [JupyterProductToInstall.nbconvert]: Product.nbconvert, - [JupyterProductToInstall.notebook]: Product.notebook, - [JupyterProductToInstall.pandas]: Product.pandas, - [JupyterProductToInstall.pip]: Product.pip, -}; type PythonApiForJupyterExtension = { - /** - * IInterpreterService - */ - onDidChangeInterpreter: Event; - /** - * IInterpreterService - */ - readonly refreshPromise: Promise | undefined; - /** - * IInterpreterService - */ - readonly onDidChangeInterpreters: Event; - /** - * Equivalent to getInterpreters() in IInterpreterService - */ - getKnownInterpreters(resource?: Uri): PythonEnvironment[]; - /** - * @deprecated Use `getKnownInterpreters`, `onDidChangeInterpreters`, and `refreshPromise` instead. - * Equivalent to getAllInterpreters() in IInterpreterService - */ - getInterpreters(resource?: Uri): Promise; - /** - * IInterpreterService - */ - getActiveInterpreter(resource?: Uri): Promise; - /** - * IInterpreterService - */ - getInterpreterDetails(pythonPath: string, resource?: Uri): Promise; - /** * IEnvironmentActivationService */ @@ -99,31 +28,11 @@ type PythonApiForJupyterExtension = { interpreter?: PythonEnvironment, allowExceptions?: boolean, ): Promise; - isMicrosoftStoreInterpreter(pythonPath: string): Promise; - suggestionToQuickPickItem(suggestion: PythonEnvironment, workspaceUri?: Uri | undefined): IInterpreterQuickPickItem; getKnownSuggestions(resource: Resource): IInterpreterQuickPickItem[]; /** * @deprecated Use `getKnownSuggestions` and `suggestionToQuickPickItem` instead. */ getSuggestions(resource: Resource): Promise; - /** - * IInstaller - */ - install( - product: JupyterProductToInstall, - resource?: InterpreterUri, - cancel?: CancellationToken, - reInstallAndUpdate?: boolean, - installPipIfRequired?: boolean, - ): Promise; - /** - * IInstaller - */ - isProductVersionCompatible( - product: Product, - semVerRequirement: string, - resource?: InterpreterUri, - ): Promise; /** * Returns path to where `debugpy` is. In python extension this is `/pythonFiles/lib/python`. */ @@ -141,10 +50,6 @@ type PythonApiForJupyterExtension = { * Returns the conda executable. */ getCondaFile(): Promise; - getEnvironmentActivationShellCommands( - resource: Resource, - interpreter?: PythonEnvironment, - ): Promise; /** * Call to provide a function that the Python extension can call to request the Python @@ -168,17 +73,6 @@ type JupyterExtensionApi = { * @param interpreterService */ registerPythonApi(interpreterService: PythonApiForJupyterExtension): void; - /** - * 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; - /** - * 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; }; @injectable() @@ -193,13 +87,10 @@ export class JupyterExtensionIntegration { constructor( @inject(IExtensions) private readonly extensions: IExtensions, - @inject(IInterpreterService) private readonly interpreterService: IInterpreterService, @inject(IInterpreterSelector) private readonly interpreterSelector: IInterpreterSelector, - @inject(IInstaller) private readonly installer: IInstaller, @inject(IEnvironmentActivationService) private readonly envActivation: IEnvironmentActivationService, @inject(IMemento) @named(GLOBAL_MEMENTO) private globalState: Memento, @inject(IInterpreterDisplay) private interpreterDisplay: IInterpreterDisplay, - @inject(IComponentAdapter) private pyenvs: IComponentAdapter, @inject(IWorkspaceService) private workspaceService: IWorkspaceService, @inject(ICondaService) private readonly condaService: ICondaService, @inject(IContextKeyManager) private readonly contextManager: IContextKeyManager, @@ -213,54 +104,15 @@ export class JupyterExtensionIntegration { } // Forward python parts jupyterExtensionApi.registerPythonApi({ - onDidChangeInterpreter: this.interpreterService.onDidChangeInterpreter, - getActiveInterpreter: async (resource?: Uri) => this.interpreterService.getActiveInterpreter(resource), - getInterpreterDetails: async (pythonPath: string) => - this.interpreterService.getInterpreterDetails(pythonPath), - refreshPromise: this.interpreterService.refreshPromise, - onDidChangeInterpreters: this.interpreterService.onDidChangeInterpreters, - getKnownInterpreters: (resource: Uri | undefined) => this.pyenvs.getInterpreters(resource), - getInterpreters: async (resource: Uri | undefined) => this.interpreterService.getAllInterpreters(resource), getActivatedEnvironmentVariables: async ( resource: Resource, interpreter?: PythonEnvironment, allowExceptions?: boolean, ) => this.envActivation.getActivatedEnvironmentVariables(resource, interpreter, allowExceptions), - isMicrosoftStoreInterpreter: async (pythonPath: string): Promise => - this.pyenvs.isMicrosoftStoreInterpreter(pythonPath), getSuggestions: async (resource: Resource): Promise => this.interpreterSelector.getAllSuggestions(resource), getKnownSuggestions: (resource: Resource): IInterpreterQuickPickItem[] => this.interpreterSelector.getSuggestions(resource), - suggestionToQuickPickItem: ( - suggestion: PythonEnvironment, - workspaceUri?: Uri | undefined, - ): IInterpreterQuickPickItem => - this.interpreterSelector.suggestionToQuickPickItem(suggestion, workspaceUri), - install: async ( - product: JupyterProductToInstall, - resource?: InterpreterUri, - cancel?: CancellationToken, - reInstallAndUpdate?: boolean, - installPipIfRequired?: boolean, - ): Promise => { - let flags = - reInstallAndUpdate === true - ? ModuleInstallFlags.updateDependencies | ModuleInstallFlags.reInstall - : undefined; - if (installPipIfRequired === true) { - flags = flags - ? flags | ModuleInstallFlags.installPipIfRequired - : ModuleInstallFlags.installPipIfRequired; - } - return this.installer.install(ProductMapping[product], resource, cancel, flags); - }, - isProductVersionCompatible: async ( - product: Product, - semVerRequirement: string, - resource?: InterpreterUri, - ): Promise => - this.installer.isProductVersionCompatible(product, semVerRequirement, resource), getDebuggerPath: async () => dirname(getDebugpyPackagePath()), getInterpreterPathSelectedForJupyterServer: () => this.globalState.get('INTERPRETER_PATH_SELECTED_FOR_JUPYTER_SERVER'), @@ -269,8 +121,6 @@ export class JupyterExtensionIntegration { ), getCondaFile: () => this.condaService.getCondaFile(), getCondaVersion: () => this.condaService.getCondaVersion(), - getEnvironmentActivationShellCommands: (resource: Resource, interpreter?: PythonEnvironment) => - this.envActivation.getEnvironmentActivationShellCommands(resource, interpreter), registerJupyterPythonPathFunction: (func: (uri: Uri) => Promise) => this.registerJupyterPythonPathFunction(func), registerGetNotebookUriForTextDocumentUriFunction: (func: (textDocumentUri: Uri) => Uri | undefined) => @@ -286,24 +136,6 @@ export class JupyterExtensionIntegration { } } - public registerRemoteServerProvider(serverProvider: IJupyterUriProvider): void { - this.getExtensionApi() - .then((e) => { - if (e) { - e.registerRemoteServerProvider(serverProvider); - } - }) - .ignoreErrors(); - } - - public async showDataViewer(dataProvider: IDataViewerDataProvider, title: string): Promise { - const api = await this.getExtensionApi(); - if (api) { - return api.showDataViewer(dataProvider, title); - } - return undefined; - } - private async getExtensionApi(): Promise { if (!this.pylanceExtension) { const pylanceExtension = this.extensions.getExtension(PYLANCE_EXTENSION_ID); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/info/env.ts b/extensions/positron-python/src/client/pythonEnvironments/base/info/env.ts index 2527f18202c..12b3e519b94 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/info/env.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/info/env.ts @@ -87,7 +87,13 @@ export function areEnvsDeepEqual(env1: PythonEnvInfo, env2: PythonEnvInfo): bool env2Clone.source = env2Clone.source.sort(); const searchLocation1 = env1.searchLocation?.fsPath ?? ''; const searchLocation2 = env2.searchLocation?.fsPath ?? ''; - return isEqual(env1Clone, env2Clone) && arePathsSame(searchLocation1, searchLocation2); + const searchLocation1Scheme = env1.searchLocation?.scheme ?? ''; + const searchLocation2Scheme = env2.searchLocation?.scheme ?? ''; + return ( + isEqual(env1Clone, env2Clone) && + arePathsSame(searchLocation1, searchLocation2) && + searchLocation1Scheme === searchLocation2Scheme + ); } /** diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts index 987abf4f415..dc507b9c94b 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/activeStateLocator.ts @@ -20,6 +20,7 @@ export class ActiveStateLocator extends LazyResourceBasedLocator { traceVerbose(`Couldn't locate the state binary.`); return; } + traceVerbose(`Searching for active state environments`); const projects = await state.getProjects(); if (projects === undefined) { traceVerbose(`Couldn't fetch State Tool projects.`); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts index 9b5283f7f96..7adeeae8985 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/microsoftStoreLocator.ts @@ -87,6 +87,7 @@ export class MicrosoftStoreLocator extends FSWatchingLocator { protected doIterEnvs(): IPythonEnvsIterator { const iterator = async function* (kind: PythonEnvKind) { + traceVerbose('Searching for windows store envs'); const exes = await getMicrosoftStorePythonExes(); yield* exes.map(async (executablePath: string) => ({ kind, diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts index 97726307c57..2d7ebc2af11 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/posixKnownPathsLocator.ts @@ -26,23 +26,29 @@ export class PosixKnownPathsLocator extends Locator { } const iterator = async function* (kind: PythonEnvKind) { - // Filter out pyenv shims. They are not actual python binaries, they are used to launch - // the binaries specified in .python-version file in the cwd. We should not be reporting - // those binaries as environments. - const knownDirs = (await commonPosixBinPaths()).filter((dirname) => !isPyenvShimDir(dirname)); - let pythonBinaries = await getPythonBinFromPosixPaths(knownDirs); + traceVerbose('Searching for interpreters in posix paths locator'); + try { + // Filter out pyenv shims. They are not actual python binaries, they are used to launch + // the binaries specified in .python-version file in the cwd. We should not be reporting + // those binaries as environments. + const knownDirs = (await commonPosixBinPaths()).filter((dirname) => !isPyenvShimDir(dirname)); + let pythonBinaries = await getPythonBinFromPosixPaths(knownDirs); + traceVerbose(`Found ${pythonBinaries.length} python binaries in posix paths`); - // Filter out MacOS system installs of Python 2 if necessary. - if (isMacPython2Deprecated) { - pythonBinaries = pythonBinaries.filter((binary) => !isMacDefaultPythonPath(binary)); - } + // Filter out MacOS system installs of Python 2 if necessary. + if (isMacPython2Deprecated) { + pythonBinaries = pythonBinaries.filter((binary) => !isMacDefaultPythonPath(binary)); + } - for (const bin of pythonBinaries) { - try { - yield { executablePath: bin, kind, source: [PythonEnvSource.PathEnvVar] }; - } catch (ex) { - traceError(`Failed to process environment: ${bin}`, ex); + for (const bin of pythonBinaries) { + try { + yield { executablePath: bin, kind, source: [PythonEnvSource.PathEnvVar] }; + } catch (ex) { + traceError(`Failed to process environment: ${bin}`, ex); + } } + } catch (ex) { + traceError('Failed to process posix paths', ex); } traceVerbose('Finished searching for interpreters in posix paths locator'); }; diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts index dc3290c9993..4fd1891a179 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/pyenvLocator.ts @@ -16,6 +16,7 @@ import { traceError, traceVerbose } from '../../../../logging'; * all the environments (global or virtual) in that directory. */ async function* getPyenvEnvironments(): AsyncIterableIterator { + traceVerbose('Searching for pyenv environments'); const pyenvVersionDir = getPyenvVersionsDir(); const subDirs = getSubDirs(pyenvVersionDir, { resolveSymlinks: true }); diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts index 377b1117b85..5bfc62d99d4 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsKnownPathsLocator.ts @@ -93,8 +93,11 @@ function getDirFilesLocator( // rather than in each low-level locator. In the meantime we // take a naive approach. async function* iterEnvs(query: PythonLocatorQuery): IPythonEnvsIterator { - yield* await getEnvs(locator.iterEnvs(query)); - traceVerbose('Finished searching for windows path interpreters'); + traceVerbose('Searching for windows path interpreters'); + yield* await getEnvs(locator.iterEnvs(query)).then((res) => { + traceVerbose('Finished searching for windows path interpreters'); + return res; + }); } return { providerId: locator.providerId, diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts index 954d1bfd2a4..b52e9c35779 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/lowLevel/windowsRegistryLocator.ts @@ -14,6 +14,7 @@ export class WindowsRegistryLocator extends Locator { // eslint-disable-next-line class-methods-use-this public iterEnvs(): IPythonEnvsIterator { const iterator = async function* () { + traceVerbose('Searching for windows registry interpreters'); const interpreters = await getRegistryInterpreters(); for (const interpreter of interpreters) { try { diff --git a/extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts b/extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts index 54f614ebdd4..ecb6f2212ab 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/common/externalDependencies.ts @@ -10,7 +10,7 @@ import { IDisposable, IConfigurationService } from '../../common/types'; import { chain, iterable } from '../../common/utils/async'; import { getOSType, OSType } from '../../common/utils/platform'; import { IServiceContainer } from '../../ioc/types'; -import { traceVerbose } from '../../logging'; +import { traceError, traceVerbose } from '../../logging'; let internalServiceContainer: IServiceContainer; export function initializeExternalDependencies(serviceContainer: IServiceContainer): void { @@ -93,16 +93,21 @@ export function arePathsSame(path1: string, path2: string): boolean { return normCasePath(path1) === normCasePath(path2); } -export async function resolveSymbolicLink(absPath: string, stats?: fsapi.Stats): Promise { +export async function resolveSymbolicLink(absPath: string, stats?: fsapi.Stats, count?: number): Promise { stats = stats ?? (await fsapi.lstat(absPath)); if (stats.isSymbolicLink()) { + if (count && count > 5) { + traceError(`Detected a potential symbolic link loop at ${absPath}, terminating resolution.`); + return absPath; + } const link = await fsapi.readlink(absPath); // Result from readlink is not guaranteed to be an absolute path. For eg. on Mac it resolves // /usr/local/bin/python3.9 -> ../../../Library/Frameworks/Python.framework/Versions/3.9/bin/python3.9 // // The resultant path is reported relative to the symlink directory we resolve. Convert that to absolute path. const absLinkPath = path.isAbsolute(link) ? link : path.resolve(path.dirname(absPath), link); - return resolveSymbolicLink(absLinkPath); + count = count ? count + 1 : 1; + return resolveSymbolicLink(absLinkPath, undefined, count); } return absPath; } diff --git a/extensions/positron-python/src/client/pythonEnvironments/common/posixUtils.ts b/extensions/positron-python/src/client/pythonEnvironments/common/posixUtils.ts index cd8f62bf9a0..eb60fc02994 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/common/posixUtils.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/common/posixUtils.ts @@ -7,7 +7,7 @@ import * as path from 'path'; import { uniq } from 'lodash'; import { getSearchPathEntries } from '../../common/utils/exec'; import { resolveSymbolicLink } from './externalDependencies'; -import { traceError, traceInfo } from '../../logging'; +import { traceError, traceInfo, traceVerbose } from '../../logging'; /** * Determine if the given filename looks like the simplest Python executable. @@ -123,6 +123,7 @@ export async function getPythonBinFromPosixPaths(searchDirs: string[]): Promise< // Ensure that we have a collection of unique global binaries by // resolving all symlinks to the target binaries. try { + traceVerbose(`Attempting to resolve symbolic link: ${filepath}`); const resolvedBin = await resolveSymbolicLink(filepath); if (binToLinkMap.has(resolvedBin)) { binToLinkMap.get(resolvedBin)?.push(filepath); diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/common/commonUtils.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/common/commonUtils.ts index 0e303e70138..b4d4a37eae9 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/common/commonUtils.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/common/commonUtils.ts @@ -1,9 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { WorkspaceFolder } from 'vscode'; import { Commands } from '../../../common/constants'; import { Common } from '../../../common/utils/localize'; import { executeCommand } from '../../../common/vscodeApis/commandApis'; import { showErrorMessage } from '../../../common/vscodeApis/windowApis'; +import { isWindows } from '../../../common/platform/platformService'; export async function showErrorMessageWithLogs(message: string): Promise { const result = await showErrorMessage(message, Common.openOutputPanel, Common.selectPythonInterpreter); @@ -13,3 +17,18 @@ export async function showErrorMessageWithLogs(message: string): Promise { await executeCommand(Commands.Set_Interpreter); } } + +export function getVenvPath(workspaceFolder: WorkspaceFolder): string { + return path.join(workspaceFolder.uri.fsPath, '.venv'); +} + +export async function hasVenv(workspaceFolder: WorkspaceFolder): Promise { + return fs.pathExists(getVenvPath(workspaceFolder)); +} + +export function getVenvExecutable(workspaceFolder: WorkspaceFolder): string { + if (isWindows()) { + return path.join(getVenvPath(workspaceFolder), 'Scripts', 'python.exe'); + } + return path.join(getVenvPath(workspaceFolder), 'bin', 'python'); +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts index 8c9817f83b7..5d51b6186b1 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/common/installCheckUtils.ts @@ -6,6 +6,7 @@ import { installedCheckScript } from '../../../common/process/internal/scripts'; import { plainExec } from '../../../common/process/rawProcessApis'; import { IInterpreterPathService } from '../../../common/types'; import { traceInfo, traceVerbose, traceError } from '../../../logging'; +import { getConfiguration } from '../../../common/vscodeApis/workspaceApis'; interface PackageDiagnostic { package: string; @@ -39,6 +40,21 @@ function parseDiagnostics(data: string): Diagnostic[] { return diagnostics; } +function getMissingPackageSeverity(doc: TextDocument): number { + const config = getConfiguration('python', doc.uri); + const severity: string = config.get('missingPackage.severity', 'Hint'); + if (severity === 'Error') { + return DiagnosticSeverity.Error; + } + if (severity === 'Warning') { + return DiagnosticSeverity.Warning; + } + if (severity === 'Information') { + return DiagnosticSeverity.Information; + } + return DiagnosticSeverity.Hint; +} + export async function getInstalledPackagesDiagnostics( interpreterPathService: IInterpreterPathService, doc: TextDocument, @@ -47,7 +63,11 @@ export async function getInstalledPackagesDiagnostics( const scriptPath = installedCheckScript(); try { traceInfo('Running installed packages checker: ', interpreter, scriptPath, doc.uri.fsPath); - const result = await plainExec(interpreter, [scriptPath, doc.uri.fsPath]); + const result = await plainExec(interpreter, [scriptPath, doc.uri.fsPath], { + env: { + VSCODE_MISSING_PGK_SEVERITY: `${getMissingPackageSeverity(doc)}`, + }, + }); traceVerbose('Installed packages check result:\n', result.stdout); if (result.stderr) { traceError('Installed packages check error:\n', result.stderr); diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts index 4593ff1abf9..f026a1a82cc 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -33,14 +33,12 @@ function fireStartedEvent(options?: CreateEnvironmentOptions): void { } function fireExitedEvent(result?: CreateEnvironmentResult, options?: CreateEnvironmentOptions, error?: Error): void { - onCreateEnvironmentExitedEvent.fire({ - options, - workspaceFolder: result?.workspaceFolder, - path: result?.path, - action: result?.action, - error: error || result?.error, - }); startedEventCount -= 1; + if (result) { + onCreateEnvironmentExitedEvent.fire({ options, ...result }); + } else if (error) { + onCreateEnvironmentExitedEvent.fire({ options, error }); + } } export function getCreationEvents(): { @@ -195,5 +193,8 @@ export async function handleCreateEnvironmentCommand( } } - return result; + if (result) { + return Object.freeze(result); + } + return undefined; } diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts index 0120b2a6e8d..ea520fdd27e 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts @@ -40,40 +40,83 @@ export interface EnvironmentWillCreateEvent { /** * Options used to create a Python environment. */ - options: CreateEnvironmentOptions | undefined; + readonly options: CreateEnvironmentOptions | undefined; } +export type CreateEnvironmentResult = + | { + /** + * Workspace folder associated with the environment. + */ + readonly workspaceFolder?: WorkspaceFolder; + + /** + * Path to the executable python in the environment + */ + readonly path: string; + + /** + * User action that resulted in exit from the create environment flow. + */ + readonly action?: CreateEnvironmentUserActions; + + /** + * Error if any occurred during environment creation. + */ + readonly error?: Error; + } + | { + /** + * Workspace folder associated with the environment. + */ + readonly workspaceFolder?: WorkspaceFolder; + + /** + * Path to the executable python in the environment + */ + readonly path?: string; + + /** + * User action that resulted in exit from the create environment flow. + */ + readonly action: CreateEnvironmentUserActions; + + /** + * Error if any occurred during environment creation. + */ + readonly error?: Error; + } + | { + /** + * Workspace folder associated with the environment. + */ + readonly workspaceFolder?: WorkspaceFolder; + + /** + * Path to the executable python in the environment + */ + readonly path?: string; + + /** + * User action that resulted in exit from the create environment flow. + */ + readonly action?: CreateEnvironmentUserActions; + + /** + * Error if any occurred during environment creation. + */ + readonly error: Error; + }; + /** * Params passed on `onDidCreateEnvironment` event handler. */ -export interface EnvironmentDidCreateEvent extends CreateEnvironmentResult { +export type EnvironmentDidCreateEvent = CreateEnvironmentResult & { /** * Options used to create the Python environment. */ - options: CreateEnvironmentOptions | undefined; -} - -export interface CreateEnvironmentResult { - /** - * Workspace folder associated with the environment. - */ - workspaceFolder: WorkspaceFolder | undefined; - - /** - * Path to the executable python in the environment - */ - path: string | undefined; - - /** - * User action that resulted in exit from the create environment flow. - */ - action: CreateEnvironmentUserActions | undefined; - - /** - * Error if any occurred during environment creation. - */ - error: Error | undefined; -} + readonly options: CreateEnvironmentOptions | undefined; +}; /** * Extensions that want to contribute their own environment creation can do that by registering an object @@ -120,14 +163,14 @@ export interface ProposedCreateEnvironmentAPI { * provider (including internal providers). This will also receive any options passed in * or defaults used to create environment. */ - onWillCreateEnvironment: Event; + readonly onWillCreateEnvironment: Event; /** * This API can be used to detect when the environment provider exits for any registered * provider (including internal providers). This will also receive created environment path, * any errors, or user actions taken from the provider. */ - onDidCreateEnvironment: Event; + readonly onDidCreateEnvironment: Event; /** * This API will show a QuickPick to select an environment provider from available list of diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts index 39cd40afd41..7ca44c1b7ef 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts @@ -4,7 +4,7 @@ import { CancellationToken, ProgressLocation, WorkspaceFolder } from 'vscode'; import * as path from 'path'; import { Commands, PVSC_EXTENSION_ID } from '../../../common/constants'; -import { traceError, traceLog } from '../../../logging'; +import { traceError, traceInfo, traceLog } from '../../../logging'; import { CreateEnvironmentProgress } from '../types'; import { pickWorkspaceFolder } from '../common/workspaceSelection'; import { execObservable } from '../../../common/process/rawProcessApis'; @@ -77,7 +77,7 @@ async function createCondaEnv( args: string[], progress: CreateEnvironmentProgress, token?: CancellationToken, -): Promise { +): Promise { progress.report({ message: CreateEnv.Conda.creating, }); @@ -128,7 +128,9 @@ async function createCondaEnv( dispose(); if (proc?.exitCode !== 0) { traceError('Error while running venv creation script: ', progressAndTelemetry.getLastError()); - deferred.reject(progressAndTelemetry.getLastError()); + deferred.reject( + progressAndTelemetry.getLastError() || `Conda env creation failed with exitCode: ${proc?.exitCode}`, + ); } else { deferred.resolve(condaEnvPath); } @@ -174,6 +176,7 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise => { - let hasError = false; - progress.report({ message: CreateEnv.statusStarting, }); @@ -237,17 +239,20 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise Python for more info.'); + } else { + throw new Error('A workspace is needed to create conda environment'); } } catch (ex) { traceError(ex); - hasError = true; - throw ex; - } finally { - if (hasError) { - showErrorMessageWithLogs(CreateEnv.Conda.errorCreatingEnvironment); - } + showErrorMessageWithLogs(CreateEnv.Conda.errorCreatingEnvironment); + return { error: ex as Error }; } - return { path: envPath, workspaceFolder: workspace, action: undefined, error: undefined }; }, ); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts index b00682c3cb5..edbdcd7d84a 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvCreationProvider.ts @@ -8,7 +8,7 @@ import { createVenvScript } from '../../../common/process/internal/scripts'; import { execObservable } from '../../../common/process/rawProcessApis'; import { createDeferred } from '../../../common/utils/async'; import { Common, CreateEnv } from '../../../common/utils/localize'; -import { traceError, traceLog, traceVerbose } from '../../../logging'; +import { traceError, traceInfo, traceLog, traceVerbose } from '../../../logging'; import { CreateEnvironmentProgress } from '../types'; import { pickWorkspaceFolder } from '../common/workspaceSelection'; import { IInterpreterQuickPick } from '../../../interpreter/configuration/types'; @@ -17,8 +17,14 @@ import { MultiStepAction, MultiStepNode, withProgress } from '../../../common/vs import { sendTelemetryEvent } from '../../../telemetry'; import { EventName } from '../../../telemetry/constants'; import { VenvProgressAndTelemetry, VENV_CREATED_MARKER, VENV_EXISTING_MARKER } from './venvProgressAndTelemetry'; -import { showErrorMessageWithLogs } from '../common/commonUtils'; -import { IPackageInstallSelection, pickPackagesToInstall } from './venvUtils'; +import { getVenvExecutable, showErrorMessageWithLogs } from '../common/commonUtils'; +import { + ExistingVenvAction, + IPackageInstallSelection, + deleteEnvironment, + pickExistingVenvAction, + pickPackagesToInstall, +} from './venvUtils'; import { InputFlowAction } from '../../../common/utils/multiStepInput'; import { CreateEnvironmentProvider, @@ -114,7 +120,10 @@ async function createVenv( dispose(); if (proc?.exitCode !== 0) { traceError('Error while running venv creation script: ', progressAndTelemetry.getLastError()); - deferred.reject(progressAndTelemetry.getLastError()); + deferred.reject( + progressAndTelemetry.getLastError() || + `Failed to create virtual environment with exitCode: ${proc?.exitCode}`, + ); } else { deferred.resolve(venvPath); } @@ -144,38 +153,72 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { traceError('Workspace was not selected or found for creating virtual environment.'); return MultiStepAction.Cancel; } + traceInfo(`Selected workspace ${workspace.uri.fsPath} for creating virtual environment.`); return MultiStepAction.Continue; }, undefined, ); - let interpreter: string | undefined; - const interpreterStep = new MultiStepNode( + let existingVenvAction: ExistingVenvAction | undefined; + const existingEnvStep = new MultiStepNode( workspaceStep, - async () => { - if (workspace) { + async (context?: MultiStepAction) => { + if (workspace && context === MultiStepAction.Continue) { try { - interpreter = await this.interpreterQuickPick.getInterpreterViaQuickPick( - workspace.uri, - (i: PythonEnvironment) => - [ - EnvironmentType.System, - EnvironmentType.MicrosoftStore, - EnvironmentType.Global, - EnvironmentType.Pyenv, - ].includes(i.envType) && i.type === undefined, // only global intepreters - { - skipRecommended: true, - showBackButton: true, - placeholder: CreateEnv.Venv.selectPythonPlaceHolder, - title: null, - }, - ); + existingVenvAction = await pickExistingVenvAction(workspace); + return MultiStepAction.Continue; } catch (ex) { - if (ex === InputFlowAction.back) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; + } + } else if (context === MultiStepAction.Back) { + return MultiStepAction.Back; + } + return MultiStepAction.Continue; + }, + undefined, + ); + workspaceStep.next = existingEnvStep; + + let interpreter: string | undefined; + const interpreterStep = new MultiStepNode( + existingEnvStep, + async (context?: MultiStepAction) => { + if (workspace) { + if ( + existingVenvAction === ExistingVenvAction.Recreate || + existingVenvAction === ExistingVenvAction.Create + ) { + try { + interpreter = await this.interpreterQuickPick.getInterpreterViaQuickPick( + workspace.uri, + (i: PythonEnvironment) => + [ + EnvironmentType.System, + EnvironmentType.MicrosoftStore, + EnvironmentType.Global, + EnvironmentType.Pyenv, + ].includes(i.envType) && i.type === undefined, // only global intepreters + { + skipRecommended: true, + showBackButton: true, + placeholder: CreateEnv.Venv.selectPythonPlaceHolder, + title: null, + }, + ); + } catch (ex) { + if (ex === InputFlowAction.back) { + return MultiStepAction.Back; + } + interpreter = undefined; + } + } else if (existingVenvAction === ExistingVenvAction.UseExisting) { + if (context === MultiStepAction.Back) { return MultiStepAction.Back; } - interpreter = undefined; + interpreter = getVenvExecutable(workspace); } } @@ -183,11 +226,12 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { traceError('Virtual env creation requires an interpreter.'); return MultiStepAction.Cancel; } + traceInfo(`Selected interpreter ${interpreter} for creating virtual environment.`); return MultiStepAction.Continue; }, undefined, ); - workspaceStep.next = interpreterStep; + existingEnvStep.next = interpreterStep; let addGitIgnore = true; let installPackages = true; @@ -198,19 +242,23 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { let installInfo: IPackageInstallSelection[] | undefined; const packagesStep = new MultiStepNode( interpreterStep, - async () => { + async (context?: MultiStepAction) => { if (workspace && installPackages) { - try { - installInfo = await pickPackagesToInstall(workspace); - } catch (ex) { - if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { - return ex; + if (existingVenvAction !== ExistingVenvAction.UseExisting) { + try { + installInfo = await pickPackagesToInstall(workspace); + } catch (ex) { + if (ex === MultiStepAction.Back || ex === MultiStepAction.Cancel) { + return ex; + } + throw ex; } - throw ex; - } - if (!installInfo) { - traceVerbose('Virtual env creation exited during dependencies selection.'); - return MultiStepAction.Cancel; + if (!installInfo) { + traceVerbose('Virtual env creation exited during dependencies selection.'); + return MultiStepAction.Cancel; + } + } else if (context === MultiStepAction.Back) { + return MultiStepAction.Back; } } @@ -225,6 +273,32 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { throw action; } + if (workspace) { + if (existingVenvAction === ExistingVenvAction.Recreate) { + sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, { + environmentType: 'venv', + status: 'triggered', + }); + if (await deleteEnvironment(workspace, interpreter)) { + sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, { + environmentType: 'venv', + status: 'deleted', + }); + } else { + sendTelemetryEvent(EventName.ENVIRONMENT_DELETE, undefined, { + environmentType: 'venv', + status: 'failed', + }); + throw MultiStepAction.Cancel; + } + } else if (existingVenvAction === ExistingVenvAction.UseExisting) { + sendTelemetryEvent(EventName.ENVIRONMENT_REUSE, undefined, { + environmentType: 'venv', + }); + return { path: getVenvExecutable(workspace), workspaceFolder: workspace }; + } + } + const args = generateCommandArgs(installInfo, addGitIgnore); return withProgress( @@ -237,8 +311,6 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { progress: CreateEnvironmentProgress, token: CancellationToken, ): Promise => { - let hasError = false; - progress.report({ message: CreateEnv.statusStarting, }); @@ -247,18 +319,19 @@ export class VenvCreationProvider implements CreateEnvironmentProvider { try { if (interpreter && workspace) { envPath = await createVenv(workspace, interpreter, args, progress, token); + if (envPath) { + return { path: envPath, workspaceFolder: workspace }; + } + throw new Error('Failed to create virtual environment. See Output > Python for more info.'); } + throw new Error( + 'Failed to create virtual environment. Either interpreter or workspace is undefined.', + ); } catch (ex) { traceError(ex); - hasError = true; - throw ex; - } finally { - if (hasError) { - showErrorMessageWithLogs(CreateEnv.Venv.errorCreatingEnvironment); - } + showErrorMessageWithLogs(CreateEnv.Venv.errorCreatingEnvironment); + return { error: ex as Error }; } - - return { path: envPath, workspaceFolder: workspace, action: undefined, error: undefined }; }, ); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvDeleteUtils.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvDeleteUtils.ts new file mode 100644 index 00000000000..46a0adf0f22 --- /dev/null +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvDeleteUtils.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as fs from 'fs-extra'; +import * as path from 'path'; +import { WorkspaceFolder } from 'vscode'; +import { traceError, traceInfo } from '../../../logging'; +import { getVenvPath, showErrorMessageWithLogs } from '../common/commonUtils'; +import { CreateEnv } from '../../../common/utils/localize'; +import { sleep } from '../../../common/utils/async'; +import { switchSelectedPython } from './venvSwitchPython'; + +async function tryDeleteFile(file: string): Promise { + try { + if (!(await fs.pathExists(file))) { + return true; + } + await fs.unlink(file); + return true; + } catch (err) { + traceError(`Failed to delete file [${file}]:`, err); + return false; + } +} + +async function tryDeleteDir(dir: string): Promise { + try { + if (!(await fs.pathExists(dir))) { + return true; + } + await fs.rmdir(dir, { + recursive: true, + maxRetries: 10, + retryDelay: 200, + }); + return true; + } catch (err) { + traceError(`Failed to delete directory [${dir}]:`, err); + return false; + } +} + +export async function deleteEnvironmentNonWindows(workspaceFolder: WorkspaceFolder): Promise { + const venvPath = getVenvPath(workspaceFolder); + if (await tryDeleteDir(venvPath)) { + traceInfo(`Deleted venv dir: ${venvPath}`); + return true; + } + showErrorMessageWithLogs(CreateEnv.Venv.errorDeletingEnvironment); + return false; +} + +export async function deleteEnvironmentWindows( + workspaceFolder: WorkspaceFolder, + interpreter: string | undefined, +): Promise { + const venvPath = getVenvPath(workspaceFolder); + const venvPythonPath = path.join(venvPath, 'Scripts', 'python.exe'); + + if (await tryDeleteFile(venvPythonPath)) { + traceInfo(`Deleted python executable: ${venvPythonPath}`); + if (await tryDeleteDir(venvPath)) { + traceInfo(`Deleted ".venv" dir: ${venvPath}`); + return true; + } + + traceError(`Failed to delete ".venv" dir: ${venvPath}`); + traceError( + 'This happens if the virtual environment is still in use, or some binary in the venv is still running.', + ); + traceError(`Please delete the ".venv" manually: [${venvPath}]`); + showErrorMessageWithLogs(CreateEnv.Venv.errorDeletingEnvironment); + return false; + } + traceError(`Failed to delete python executable: ${venvPythonPath}`); + traceError('This happens if the virtual environment is still in use.'); + + if (interpreter) { + traceError('We will attempt to switch python temporarily to delete the ".venv"'); + + await switchSelectedPython(interpreter, workspaceFolder.uri, 'temporarily to delete the ".venv"'); + + traceInfo(`Attempting to delete ".venv" again: ${venvPath}`); + const ms = 500; + for (let i = 0; i < 5; i = i + 1) { + traceInfo(`Waiting for ${ms}ms to let processes exit, before a delete attempt.`); + await sleep(ms); + if (await tryDeleteDir(venvPath)) { + traceInfo(`Deleted ".venv" dir: ${venvPath}`); + return true; + } + traceError(`Failed to delete ".venv" dir [${venvPath}] (attempt ${i + 1}/5).`); + } + } else { + traceError(`Please delete the ".venv" dir manually: [${venvPath}]`); + } + showErrorMessageWithLogs(CreateEnv.Venv.errorDeletingEnvironment); + return false; +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvSwitchPython.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvSwitchPython.ts new file mode 100644 index 00000000000..e2567dfd114 --- /dev/null +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvSwitchPython.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { Disposable, Uri } from 'vscode'; +import { createDeferred } from '../../../common/utils/async'; +import { getExtension } from '../../../common/vscodeApis/extensionsApi'; +import { PVSC_EXTENSION_ID, PythonExtension } from '../../../api/types'; +import { traceInfo } from '../../../logging'; + +export async function switchSelectedPython(interpreter: string, uri: Uri, purpose: string): Promise { + let dispose: Disposable | undefined; + try { + const deferred = createDeferred(); + const api: PythonExtension = getExtension(PVSC_EXTENSION_ID)?.exports as PythonExtension; + dispose = api.environments.onDidChangeActiveEnvironmentPath(async (e) => { + if (path.normalize(e.path) === path.normalize(interpreter)) { + traceInfo(`Switched to interpreter ${purpose}: ${interpreter}`); + deferred.resolve(); + } + }); + api.environments.updateActiveEnvironmentPath(interpreter, uri); + traceInfo(`Switching interpreter ${purpose}: ${interpreter}`); + await deferred.promise; + } finally { + dispose?.dispose(); + } +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvUtils.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvUtils.ts index 7c6505082fb..d7a0be170f9 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvUtils.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvUtils.ts @@ -5,11 +5,20 @@ import * as tomljs from '@iarna/toml'; import * as fs from 'fs-extra'; import { flatten, isArray } from 'lodash'; import * as path from 'path'; -import { CancellationToken, QuickPickItem, RelativePattern, WorkspaceFolder } from 'vscode'; -import { CreateEnv } from '../../../common/utils/localize'; -import { MultiStepAction, MultiStepNode, showQuickPickWithBack } from '../../../common/vscodeApis/windowApis'; +import { CancellationToken, ProgressLocation, QuickPickItem, RelativePattern, WorkspaceFolder } from 'vscode'; +import { Common, CreateEnv } from '../../../common/utils/localize'; +import { + MultiStepAction, + MultiStepNode, + showQuickPickWithBack, + withProgress, +} from '../../../common/vscodeApis/windowApis'; import { findFiles } from '../../../common/vscodeApis/workspaceApis'; import { traceError, traceVerbose } from '../../../logging'; +import { Commands } from '../../../common/constants'; +import { isWindows } from '../../../common/platform/platformService'; +import { getVenvPath, hasVenv } from '../common/commonUtils'; +import { deleteEnvironmentNonWindows, deleteEnvironmentWindows } from './venvDeleteUtils'; const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**'; async function getPipRequirementsFiles( @@ -226,3 +235,66 @@ export async function pickPackagesToInstall( return packages; } + +export async function deleteEnvironment( + workspaceFolder: WorkspaceFolder, + interpreter: string | undefined, +): Promise { + const venvPath = getVenvPath(workspaceFolder); + return withProgress( + { + location: ProgressLocation.Notification, + title: `${CreateEnv.Venv.deletingEnvironmentProgress} ([${Common.showLogs}](command:${Commands.ViewOutput})): ${venvPath}`, + cancellable: false, + }, + async () => { + if (isWindows()) { + return deleteEnvironmentWindows(workspaceFolder, interpreter); + } + return deleteEnvironmentNonWindows(workspaceFolder); + }, + ); +} + +export enum ExistingVenvAction { + Recreate, + UseExisting, + Create, +} + +export async function pickExistingVenvAction( + workspaceFolder: WorkspaceFolder | undefined, +): Promise { + if (workspaceFolder) { + if (await hasVenv(workspaceFolder)) { + const items: QuickPickItem[] = [ + { label: CreateEnv.Venv.recreate, description: CreateEnv.Venv.recreateDescription }, + { + label: CreateEnv.Venv.useExisting, + description: CreateEnv.Venv.useExistingDescription, + }, + ]; + + const selection = (await showQuickPickWithBack( + items, + { + placeHolder: CreateEnv.Venv.existingVenvQuickPickPlaceholder, + ignoreFocusOut: true, + }, + undefined, + )) as QuickPickItem | undefined; + + if (selection?.label === CreateEnv.Venv.recreate) { + return ExistingVenvAction.Recreate; + } + + if (selection?.label === CreateEnv.Venv.useExisting) { + return ExistingVenvAction.UseExisting; + } + } else { + return ExistingVenvAction.Create; + } + } + + throw MultiStepAction.Cancel; +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/info/index.ts b/extensions/positron-python/src/client/pythonEnvironments/info/index.ts index 60628f61314..ee2ff9d7cc2 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/info/index.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/info/index.ts @@ -4,6 +4,7 @@ 'use strict'; import { Architecture } from '../../common/utils/platform'; +import { PythonEnvType } from '../base/info'; import { PythonVersion } from './pythonVersion'; /** @@ -85,7 +86,7 @@ export type PythonEnvironment = InterpreterInformation & { envName?: string; envPath?: string; cachedEntry?: boolean; - type?: string; + type?: PythonEnvType; }; /** diff --git a/extensions/positron-python/src/client/startupTelemetry.ts b/extensions/positron-python/src/client/startupTelemetry.ts index f4d3fc254b6..43cf09c23c3 100644 --- a/extensions/positron-python/src/client/startupTelemetry.ts +++ b/extensions/positron-python/src/client/startupTelemetry.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as vscode from 'vscode'; import { IWorkspaceService } from './common/application/types'; import { isTestExecution } from './common/constants'; import { ITerminalHelper } from './common/terminal/types'; @@ -81,6 +82,7 @@ async function getActivationTelemetryProps(serviceContainer: IServiceContainer): // TODO: If any one of these parts fails we send no info. We should // be able to partially populate as much as possible instead // (through granular try-catch statements). + const appName = vscode.env.appName; const workspaceService = serviceContainer.get(IWorkspaceService); const workspaceFolderCount = workspaceService.workspaceFolders?.length || 0; const terminalHelper = serviceContainer.get(ITerminalHelper); @@ -129,5 +131,6 @@ async function getActivationTelemetryProps(serviceContainer: IServiceContainer): hasPythonThree, usingUserDefinedInterpreter, usingGlobalInterpreter, + appName, }; } diff --git a/extensions/positron-python/src/client/telemetry/constants.ts b/extensions/positron-python/src/client/telemetry/constants.ts index 159f5690e5c..a729b3d491e 100644 --- a/extensions/positron-python/src/client/telemetry/constants.ts +++ b/extensions/positron-python/src/client/telemetry/constants.ts @@ -112,6 +112,8 @@ export enum EventName { ENVIRONMENT_INSTALLED_PACKAGES = 'ENVIRONMENT.INSTALLED_PACKAGES', ENVIRONMENT_INSTALLING_PACKAGES_FAILED = 'ENVIRONMENT.INSTALLING_PACKAGES_FAILED', ENVIRONMENT_BUTTON = 'ENVIRONMENT.BUTTON', + ENVIRONMENT_DELETE = 'ENVIRONMENT.DELETE', + ENVIRONMENT_REUSE = 'ENVIRONMENT.REUSE', TOOLS_EXTENSIONS_ALREADY_INSTALLED = 'TOOLS_EXTENSIONS.ALREADY_INSTALLED', TOOLS_EXTENSIONS_PROMPT_SHOWN = 'TOOLS_EXTENSIONS.PROMPT_SHOWN', diff --git a/extensions/positron-python/src/client/telemetry/index.ts b/extensions/positron-python/src/client/telemetry/index.ts index 50d7ebb0f9b..f4947cd73f0 100644 --- a/extensions/positron-python/src/client/telemetry/index.ts +++ b/extensions/positron-python/src/client/telemetry/index.ts @@ -729,6 +729,7 @@ export interface IEventNamePropertyMapping { */ /* __GDPR__ "editor.load" : { + "appName" : {"classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "luabud"}, "codeloadingtime" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "luabud" }, "condaversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "luabud" }, "errorname" : { "classification": "CallstackOrException", "purpose": "PerformanceAndHealth", "owner": "luabud" }, @@ -747,6 +748,10 @@ export interface IEventNamePropertyMapping { } */ [EventName.EDITOR_LOAD]: { + /** + * The name of the application where the Python extension is running + */ + appName?: string | undefined; /** * The conda version if selected */ @@ -827,6 +832,12 @@ export interface IEventNamePropertyMapping { * @type {('command' | 'icon')} */ trigger?: 'command' | 'icon'; + /** + * Whether user chose to execute this Python file in a separate terminal or not. + * + * @type {boolean} + */ + newTerminalPerFile?: boolean; }; /** * Telemetry Event sent when user executes code against Django Shell. @@ -939,7 +950,7 @@ export interface IEventNamePropertyMapping { tool?: LinterId; /** * `select` When 'Select linter' option is selected - * `disablePrompt` When 'Do not show again' option is selected + * `disablePrompt` When "Don't show again" option is selected * `install` When 'Install' option is selected * * @type {('select' | 'disablePrompt' | 'install')} @@ -1363,7 +1374,7 @@ export interface IEventNamePropertyMapping { /** * `Yes` When 'Yes' option is selected * `No` When 'No' option is selected - * `Ignore` When 'Do not show again' option is clicked + * `Ignore` When "Don't show again" option is clicked * * @type {('Yes' | 'No' | 'Ignore' | undefined)} */ @@ -1543,7 +1554,7 @@ export interface IEventNamePropertyMapping { * This event also has a measure, "resultLength", which records the number of completions provided. */ /* __GDPR__ - "jedi_language_server.request" : { + "jedi_language_server.request" : { "method": {"classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karthiknadig"} } */ @@ -1560,7 +1571,7 @@ export interface IEventNamePropertyMapping { /** * Carries the selection of user when they are asked to take the extension survey */ - selection: 'Yes' | 'Maybe later' | 'Do not show again' | undefined; + selection: 'Yes' | 'Maybe later' | "Don't show again" | undefined; }; /** * Telemetry event sent when starting REPL @@ -2109,6 +2120,30 @@ export interface IEventNamePropertyMapping { "environment.button" : {"owner": "karthiknadig" } */ [EventName.ENVIRONMENT_BUTTON]: never | undefined; + /** + * Telemetry event if user selected to delete the existing environment. + */ + /* __GDPR__ + "environment.delete" : { + "environmentType" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "karthiknadig" }, + "status" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "karthiknadig" } + } + */ + [EventName.ENVIRONMENT_DELETE]: { + environmentType: 'venv' | 'conda'; + status: 'triggered' | 'deleted' | 'failed'; + }; + /** + * Telemetry event if user selected to re-use the existing environment. + */ + /* __GDPR__ + "environment.reuse" : { + "environmentType" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "owner": "karthiknadig" } + } + */ + [EventName.ENVIRONMENT_REUSE]: { + environmentType: 'venv' | 'conda'; + }; /** * Telemetry event sent when a linter or formatter extension is already installed. */ diff --git a/extensions/positron-python/src/client/telemetry/pylance.ts b/extensions/positron-python/src/client/telemetry/pylance.ts index 905cacc5fbf..afa6b529808 100644 --- a/extensions/positron-python/src/client/telemetry/pylance.ts +++ b/extensions/positron-python/src/client/telemetry/pylance.ts @@ -6,6 +6,12 @@ "lsversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ +/* __GDPR__ + "language_server.jinja_usage" : { + "lsversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, + "openfileextensions" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } +*/ /* __GDPR__ "language_server.ready" : { "duration" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true }, @@ -50,7 +56,8 @@ "numfilesinprogram" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, "peakrssmb" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, "resolverid" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, - "rssmb" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" } + "rssmb" : { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth" }, + "diagnosticsseen" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ /* __GDPR__ diff --git a/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts b/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts index 06e9a1361f4..05265a0e918 100644 --- a/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts +++ b/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts @@ -40,25 +40,31 @@ export class CodeExecutionManager implements ICodeExecutionManager { } public registerCommands() { - [Commands.Exec_In_Terminal, Commands.Exec_In_Terminal_Icon].forEach((cmd) => { - this.disposableRegistry.push( - this.commandManager.registerCommand(cmd as any, async (file: Resource) => { - const interpreterService = this.serviceContainer.get(IInterpreterService); - const interpreter = await interpreterService.getActiveInterpreter(file); - if (!interpreter) { - this.commandManager.executeCommand(Commands.TriggerEnvironmentSelection, file).then(noop, noop); - return; - } - const trigger = cmd === Commands.Exec_In_Terminal ? 'command' : 'icon'; - await this.executeFileInTerminal(file, trigger) - .then(() => { - if (this.shouldTerminalFocusOnStart(file)) - this.commandManager.executeCommand('workbench.action.terminal.focus'); + [Commands.Exec_In_Terminal, Commands.Exec_In_Terminal_Icon, Commands.Exec_In_Separate_Terminal].forEach( + (cmd) => { + this.disposableRegistry.push( + this.commandManager.registerCommand(cmd as any, async (file: Resource) => { + const interpreterService = this.serviceContainer.get(IInterpreterService); + const interpreter = await interpreterService.getActiveInterpreter(file); + if (!interpreter) { + this.commandManager + .executeCommand(Commands.TriggerEnvironmentSelection, file) + .then(noop, noop); + return; + } + const trigger = cmd === Commands.Exec_In_Terminal ? 'command' : 'icon'; + await this.executeFileInTerminal(file, trigger, { + newTerminalPerFile: cmd === Commands.Exec_In_Separate_Terminal, }) - .catch((ex) => traceError('Failed to execute file in terminal', ex)); - }), - ); - }); + .then(() => { + if (this.shouldTerminalFocusOnStart(file)) + this.commandManager.executeCommand('workbench.action.terminal.focus'); + }) + .catch((ex) => traceError('Failed to execute file in terminal', ex)); + }), + ); + }, + ); // --- Start Positron --- this.disposableRegistry.push( this.commandManager.registerCommand(Commands.Exec_In_Console as any, async () => { @@ -142,8 +148,16 @@ export class CodeExecutionManager implements ICodeExecutionManager { ), ); } - private async executeFileInTerminal(file: Resource, trigger: 'command' | 'icon') { - sendTelemetryEvent(EventName.EXECUTION_CODE, undefined, { scope: 'file', trigger }); + private async executeFileInTerminal( + file: Resource, + trigger: 'command' | 'icon', + options?: { newTerminalPerFile: boolean }, + ): Promise { + sendTelemetryEvent(EventName.EXECUTION_CODE, undefined, { + scope: 'file', + trigger, + newTerminalPerFile: options?.newTerminalPerFile, + }); const codeExecutionHelper = this.serviceContainer.get(ICodeExecutionHelper); file = file instanceof Uri ? file : undefined; let fileToExecute = file ? file : await codeExecutionHelper.getFileToExecute(); @@ -165,7 +179,7 @@ export class CodeExecutionManager implements ICodeExecutionManager { } const executionService = this.serviceContainer.get(ICodeExecutionService, 'standard'); - await executionService.executeFile(fileToExecute); + await executionService.executeFile(fileToExecute, options); } @captureTelemetry(EventName.EXECUTION_CODE, { scope: 'selection' }, false) diff --git a/extensions/positron-python/src/client/terminals/codeExecution/terminalCodeExecution.ts b/extensions/positron-python/src/client/terminals/codeExecution/terminalCodeExecution.ts index 9261483b45e..50270c3586c 100644 --- a/extensions/positron-python/src/client/terminals/codeExecution/terminalCodeExecution.ts +++ b/extensions/positron-python/src/client/terminals/codeExecution/terminalCodeExecution.ts @@ -10,7 +10,7 @@ import { IWorkspaceService } from '../../common/application/types'; import '../../common/extensions'; import { IPlatformService } from '../../common/platform/types'; import { ITerminalService, ITerminalServiceFactory } from '../../common/terminal/types'; -import { IConfigurationService, IDisposableRegistry } from '../../common/types'; +import { IConfigurationService, IDisposableRegistry, Resource } from '../../common/types'; import { IInterpreterService } from '../../interpreter/contracts'; import { buildPythonExecInfo, PythonExecInfo } from '../../pythonEnvironments/exec'; import { ICodeExecutionService } from '../../terminals/types'; @@ -19,7 +19,6 @@ import { ICodeExecutionService } from '../../terminals/types'; export class TerminalCodeExecutionProvider implements ICodeExecutionService { private hasRanOutsideCurrentDrive = false; protected terminalTitle!: string; - private _terminalService!: ITerminalService; private replActive?: Promise; constructor( @inject(ITerminalServiceFactory) protected readonly terminalServiceFactory: ITerminalServiceFactory, @@ -30,13 +29,13 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService { @inject(IInterpreterService) protected readonly interpreterService: IInterpreterService, ) {} - public async executeFile(file: Uri) { - await this.setCwdForFileExecution(file); + public async executeFile(file: Uri, options?: { newTerminalPerFile: boolean }) { + await this.setCwdForFileExecution(file, options); const { command, args } = await this.getExecuteFileArgs(file, [ file.fsPath.fileToCommandArgumentForPythonExt(), ]); - await this.getTerminalService(file).sendCommand(command, args); + await this.getTerminalService(file, options).sendCommand(command, args); } public async execute(code: string, resource?: Uri): Promise { @@ -44,21 +43,27 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService { return; } - await this.initializeRepl(); + await this.initializeRepl(resource); await this.getTerminalService(resource).sendText(code); } - public async initializeRepl(resource?: Uri) { + public async initializeRepl(resource: Resource) { + const terminalService = this.getTerminalService(resource); if (this.replActive && (await this.replActive)) { - await this._terminalService.show(); + await terminalService.show(); return; } this.replActive = new Promise(async (resolve) => { const replCommandArgs = await this.getExecutableInfo(resource); - await this.getTerminalService(resource).sendCommand(replCommandArgs.command, replCommandArgs.args); + terminalService.sendCommand(replCommandArgs.command, replCommandArgs.args); // Give python repl time to start before we start sending text. setTimeout(() => resolve(true), 1000); }); + this.disposables.push( + terminalService.onDidCloseTerminal(() => { + this.replActive = undefined; + }), + ); await this.replActive; } @@ -76,21 +81,14 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService { public async getExecuteFileArgs(resource?: Uri, executeArgs: string[] = []): Promise { return this.getExecutableInfo(resource, executeArgs); } - private getTerminalService(resource?: Uri): ITerminalService { - if (!this._terminalService) { - this._terminalService = this.terminalServiceFactory.getTerminalService({ - resource, - title: this.terminalTitle, - }); - this.disposables.push( - this._terminalService.onDidCloseTerminal(() => { - this.replActive = undefined; - }), - ); - } - return this._terminalService; + private getTerminalService(resource: Resource, options?: { newTerminalPerFile: boolean }): ITerminalService { + return this.terminalServiceFactory.getTerminalService({ + resource, + title: this.terminalTitle, + newTerminalPerFile: options?.newTerminalPerFile, + }); } - private async setCwdForFileExecution(file: Uri) { + private async setCwdForFileExecution(file: Uri, options?: { newTerminalPerFile: boolean }) { const pythonSettings = this.configurationService.getSettings(file); if (!pythonSettings.terminal.executeInFileDir) { return; @@ -108,7 +106,9 @@ export class TerminalCodeExecutionProvider implements ICodeExecutionService { await this.getTerminalService(file).sendText(`${fileDrive}:`); } } - await this.getTerminalService(file).sendText(`cd ${fileDirPath.fileToCommandArgumentForPythonExt()}`); + await this.getTerminalService(file, options).sendText( + `cd ${fileDirPath.fileToCommandArgumentForPythonExt()}`, + ); } } } diff --git a/extensions/positron-python/src/client/terminals/types.ts b/extensions/positron-python/src/client/terminals/types.ts index cf31f4ef1dd..47ac16d9e08 100644 --- a/extensions/positron-python/src/client/terminals/types.ts +++ b/extensions/positron-python/src/client/terminals/types.ts @@ -8,7 +8,7 @@ export const ICodeExecutionService = Symbol('ICodeExecutionService'); export interface ICodeExecutionService { execute(code: string, resource?: Uri): Promise; - executeFile(file: Uri): Promise; + executeFile(file: Uri, options?: { newTerminalPerFile: boolean }): Promise; initializeRepl(resource?: Uri): Promise; } diff --git a/extensions/positron-python/src/client/testing/common/debugLauncher.ts b/extensions/positron-python/src/client/testing/common/debugLauncher.ts index dcf23c0478d..63e2a4543be 100644 --- a/extensions/positron-python/src/client/testing/common/debugLauncher.ts +++ b/extensions/positron-python/src/client/testing/common/debugLauncher.ts @@ -17,6 +17,7 @@ import { getWorkspaceFolder, getWorkspaceFolders } from '../../common/vscodeApis import { showErrorMessage } from '../../common/vscodeApis/windowApis'; import { createDeferred } from '../../common/utils/async'; import { pythonTestAdapterRewriteEnabled } from '../testController/common/utils'; +import { addPathToPythonpath } from './helpers'; @injectable() export class DebugLauncher implements ITestDebugLauncher { @@ -223,8 +224,19 @@ export class DebugLauncher implements ITestDebugLauncher { `Missing value for debug setup, both port and uuid need to be defined. port: "${options.pytestPort}" uuid: "${options.pytestUUID}"`, ); } - const pluginPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles'); - launchArgs.env.PYTHONPATH = pluginPath; + } + const pluginPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles'); + // check if PYTHONPATH is already set in the environment variables + if (launchArgs.env) { + const additionalPythonPath = [pluginPath]; + if (launchArgs.cwd) { + additionalPythonPath.push(launchArgs.cwd); + } else if (options.cwd) { + additionalPythonPath.push(options.cwd); + } + // add the plugin path or cwd to PYTHONPATH if it is not already there using the following function + // this function will handle if PYTHONPATH is undefined + addPathToPythonpath(additionalPythonPath, launchArgs.env.PYTHONPATH); } // Clear out purpose so we can detect if the configuration was used to diff --git a/extensions/positron-python/src/client/testing/common/helpers.ts b/extensions/positron-python/src/client/testing/common/helpers.ts new file mode 100644 index 00000000000..021849277b3 --- /dev/null +++ b/extensions/positron-python/src/client/testing/common/helpers.ts @@ -0,0 +1,37 @@ +import * as path from 'path'; + +/** + * This function normalizes the provided paths and the existing paths in PYTHONPATH, + * adds the provided paths to PYTHONPATH if they're not already present, + * and then returns the updated PYTHONPATH. + * + * @param newPaths - An array of paths to be added to PYTHONPATH + * @param launchPythonPath - The initial PYTHONPATH + * @returns The updated PYTHONPATH + */ +export function addPathToPythonpath(newPaths: string[], launchPythonPath: string | undefined): string { + // Split PYTHONPATH into array of paths if it exists + let paths: string[]; + if (!launchPythonPath) { + paths = []; + } else { + paths = launchPythonPath.split(path.delimiter); + } + + // Normalize each path in the existing PYTHONPATH + paths = paths.map((p) => path.normalize(p)); + + // Normalize each new path and add it to PYTHONPATH if it's not already present + newPaths.forEach((newPath) => { + const normalizedNewPath: string = path.normalize(newPath); + + if (!paths.includes(normalizedNewPath)) { + paths.push(normalizedNewPath); + } + }); + + // Join the paths with ':' to create the updated PYTHONPATH + const updatedPythonPath: string = paths.join(path.delimiter); + + return updatedPythonPath; +} diff --git a/extensions/positron-python/src/client/testing/testController/common/resultResolver.ts b/extensions/positron-python/src/client/testing/testController/common/resultResolver.ts index 8baf4d0d7ae..6e875473c83 100644 --- a/extensions/positron-python/src/client/testing/testController/common/resultResolver.ts +++ b/extensions/positron-python/src/client/testing/testController/common/resultResolver.ts @@ -68,7 +68,7 @@ export class PythonResultResolver implements ITestResultResolver { // remove error node only if no errors exist. this.testController.items.delete(`DiscoveryError:${workspacePath}`); } - if (rawTestData.tests) { + if (rawTestData.tests || rawTestData.tests === null) { // if any tests exist, they should be populated in the test tree, regardless of whether there were errors or not. // parse and insert test data. diff --git a/extensions/positron-python/src/client/testing/testController/common/server.ts b/extensions/positron-python/src/client/testing/testController/common/server.ts index f854371ffc3..8797a861fb4 100644 --- a/extensions/positron-python/src/client/testing/testController/common/server.ts +++ b/extensions/positron-python/src/client/testing/testController/common/server.ts @@ -3,10 +3,11 @@ import * as net from 'net'; import * as crypto from 'crypto'; -import { Disposable, Event, EventEmitter } from 'vscode'; +import { Disposable, Event, EventEmitter, TestRun } from 'vscode'; import * as path from 'path'; import { ExecutionFactoryCreateWithEnvironmentOptions, + ExecutionResult, IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; @@ -15,6 +16,7 @@ import { DataReceivedEvent, ITestServer, TestCommandOptions } from './types'; import { ITestDebugLauncher, LaunchOptions } from '../../common/types'; import { UNITTEST_PROVIDER } from '../../common/constants'; import { jsonRPCHeaders, jsonRPCContent, JSONRPC_UUID_HEADER } from './utils'; +import { createDeferred } from '../../../common/utils/async'; export class PythonTestServer implements ITestServer, Disposable { private _onDataReceived: EventEmitter = new EventEmitter(); @@ -140,7 +142,12 @@ export class PythonTestServer implements ITestServer, Disposable { return this._onDataReceived.event; } - async sendCommand(options: TestCommandOptions, runTestIdPort?: string, callback?: () => void): Promise { + async sendCommand( + options: TestCommandOptions, + runTestIdPort?: string, + runInstance?: TestRun, + callback?: () => void, + ): Promise { const { uuid } = options; const pythonPathParts: string[] = process.env.PYTHONPATH?.split(path.delimiter) ?? []; @@ -154,7 +161,7 @@ export class PythonTestServer implements ITestServer, Disposable { }; if (spawnOptions.extraVariables) spawnOptions.extraVariables.RUN_TEST_IDS_PORT = runTestIdPort; - const isRun = !options.testIds; + const isRun = runTestIdPort !== undefined; // Create the Python environment in which to execute the command. const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { allowEnvironmentFetchExceptions: false, @@ -195,7 +202,28 @@ export class PythonTestServer implements ITestServer, Disposable { // This means it is running discovery traceLog(`Discovering unittest tests with arguments: ${args}\r\n`); } - await execService.exec(args, spawnOptions); + const deferred = createDeferred>(); + + const result = execService.execObservable(args, spawnOptions); + + runInstance?.token.onCancellationRequested(() => { + result?.proc?.kill(); + }); + + // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. + // Displays output to user and ensure the subprocess doesn't run into buffer overflow. + result?.proc?.stdout?.on('data', (data) => { + spawnOptions?.outputChannel?.append(data.toString()); + }); + result?.proc?.stderr?.on('data', (data) => { + spawnOptions?.outputChannel?.append(data.toString()); + }); + result?.proc?.on('exit', () => { + traceLog('Exec server closed.', uuid); + deferred.resolve({ stdout: '', stderr: '' }); + callback?.(); + }); + await deferred.promise; } } catch (ex) { this.uuids = this.uuids.filter((u) => u !== uuid); diff --git a/extensions/positron-python/src/client/testing/testController/common/types.ts b/extensions/positron-python/src/client/testing/testController/common/types.ts index d4e54951bfd..16c0bd0e3ce 100644 --- a/extensions/positron-python/src/client/testing/testController/common/types.ts +++ b/extensions/positron-python/src/client/testing/testController/common/types.ts @@ -174,7 +174,12 @@ export interface ITestServer { readonly onDataReceived: Event; readonly onRunDataReceived: Event; readonly onDiscoveryDataReceived: Event; - sendCommand(options: TestCommandOptions, runTestIdsPort?: string, callback?: () => void): Promise; + sendCommand( + options: TestCommandOptions, + runTestIdsPort?: string, + runInstance?: TestRun, + callback?: () => void, + ): Promise; serverReady(): Promise; getPort(): number; createUUID(cwd: string): string; diff --git a/extensions/positron-python/src/client/testing/testController/controller.ts b/extensions/positron-python/src/client/testing/testController/controller.ts index eff333a4cdd..1550323ff8f 100644 --- a/extensions/positron-python/src/client/testing/testController/controller.ts +++ b/extensions/positron-python/src/client/testing/testController/controller.ts @@ -15,6 +15,7 @@ import { CancellationTokenSource, Uri, EventEmitter, + TextDocument, } from 'vscode'; import { IExtensionSingleActivationService } from '../../activation/types'; import { ICommandManager, IWorkspaceService } from '../../common/application/types'; @@ -48,6 +49,7 @@ import { WorkspaceTestAdapter } from './workspaceTestAdapter'; import { ITestDebugLauncher } from '../common/types'; import { IServiceContainer } from '../../ioc/types'; import { PythonResultResolver } from './common/resultResolver'; +import { onDidSaveTextDocument } from '../../common/vscodeApis/workspaceApis'; // Types gymnastics to make sure that sendTriggerTelemetry only accepts the correct types. type EventPropertyType = IEventNamePropertyMapping[EventName.UNITTEST_DISCOVERY_TRIGGER]; @@ -209,7 +211,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc if (settings.testing.autoTestDiscoverOnSaveEnabled) { traceVerbose(`Testing: Setting up watcher for ${workspace.uri.fsPath}`); this.watchForSettingsChanges(workspace); - this.watchForTestContentChanges(workspace); + this.watchForTestContentChangeOnSave(); } }); } @@ -493,12 +495,23 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.disposables.push(watcher); this.disposables.push( - watcher.onDidChange((uri) => { - traceVerbose(`Testing: Trigger refresh after change in ${uri.fsPath}`); - this.sendTriggerTelemetry('watching'); - this.refreshData.trigger(uri, false); + onDidSaveTextDocument(async (doc: TextDocument) => { + const file = doc.fileName; + // refresh on any settings file save + if ( + file.includes('settings.json') || + file.includes('pytest.ini') || + file.includes('setup.cfg') || + file.includes('pyproject.toml') + ) { + traceVerbose(`Testing: Trigger refresh after saving ${doc.uri.fsPath}`); + this.sendTriggerTelemetry('watching'); + this.refreshData.trigger(doc.uri, false); + } }), ); + /* Keep both watchers for create and delete since config files can change test behavior without content + due to their impact on pythonPath. */ this.disposables.push( watcher.onDidCreate((uri) => { traceVerbose(`Testing: Trigger refresh after creating ${uri.fsPath}`); @@ -515,31 +528,14 @@ export class PythonTestController implements ITestController, IExtensionSingleAc ); } - private watchForTestContentChanges(workspace: WorkspaceFolder): void { - const pattern = new RelativePattern(workspace, '**/*.py'); - const watcher = this.workspaceService.createFileSystemWatcher(pattern); - this.disposables.push(watcher); - - this.disposables.push( - watcher.onDidChange((uri) => { - traceVerbose(`Testing: Trigger refresh after change in ${uri.fsPath}`); - this.sendTriggerTelemetry('watching'); - // We want to invalidate tests for code change - this.refreshData.trigger(uri, true); - }), - ); - this.disposables.push( - watcher.onDidCreate((uri) => { - traceVerbose(`Testing: Trigger refresh after creating ${uri.fsPath}`); - this.sendTriggerTelemetry('watching'); - this.refreshData.trigger(uri, false); - }), - ); + private watchForTestContentChangeOnSave(): void { this.disposables.push( - watcher.onDidDelete((uri) => { - traceVerbose(`Testing: Trigger refresh after deleting in ${uri.fsPath}`); - this.sendTriggerTelemetry('watching'); - this.refreshData.trigger(uri, false); + onDidSaveTextDocument(async (doc: TextDocument) => { + if (doc.fileName.endsWith('.py')) { + traceVerbose(`Testing: Trigger refresh after saving ${doc.uri.fsPath}`); + this.sendTriggerTelemetry('watching'); + this.refreshData.trigger(doc.uri, false); + } }), ); } diff --git a/extensions/positron-python/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts b/extensions/positron-python/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts index b83224d4161..450e2ef1edf 100644 --- a/extensions/positron-python/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -4,13 +4,14 @@ import * as path from 'path'; import { Uri } from 'vscode'; import { ExecutionFactoryCreateWithEnvironmentOptions, + ExecutionResult, IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { createDeferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; -import { traceError, traceVerbose } from '../../../logging'; +import { traceVerbose } from '../../../logging'; import { DataReceivedEvent, DiscoveredTestPayload, @@ -32,27 +33,30 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { async discoverTests(uri: Uri, executionFactory?: IPythonExecutionFactory): Promise { const settings = this.configSettings.getSettings(uri); + const uuid = this.testServer.createUUID(uri.fsPath); const { pytestArgs } = settings.testing; traceVerbose(pytestArgs); - const disposable = this.testServer.onDiscoveryDataReceived((e: DataReceivedEvent) => { - // cancelation token ? + const dataReceivedDisposable = this.testServer.onDiscoveryDataReceived((e: DataReceivedEvent) => { this.resultResolver?.resolveDiscovery(JSON.parse(e.data)); }); + const disposeDataReceiver = function (testServer: ITestServer) { + testServer.deleteUUID(uuid); + dataReceivedDisposable.dispose(); + }; try { - await this.runPytestDiscovery(uri, executionFactory); + await this.runPytestDiscovery(uri, uuid, executionFactory); } finally { - disposable.dispose(); + disposeDataReceiver(this.testServer); } // this is only a placeholder to handle function overloading until rewrite is finished const discoveryPayload: DiscoveredTestPayload = { cwd: uri.fsPath, status: 'success' }; return discoveryPayload; } - async runPytestDiscovery(uri: Uri, executionFactory?: IPythonExecutionFactory): Promise { + async runPytestDiscovery(uri: Uri, uuid: string, executionFactory?: IPythonExecutionFactory): Promise { const deferred = createDeferred(); const relativePathToPytest = 'pythonFiles'; const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); - const uuid = this.testServer.createUUID(uri.fsPath); const settings = this.configSettings.getSettings(uri); const { pytestArgs } = settings.testing; const cwd = settings.testing.cwd && settings.testing.cwd.length > 0 ? settings.testing.cwd : uri.fsPath; @@ -78,17 +82,23 @@ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { }; const execService = await executionFactory?.createActivatedEnvironment(creationOptions); // delete UUID following entire discovery finishing. - execService - ?.exec(['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs), spawnOptions) - .then(() => { - this.testServer.deleteUUID(uuid); - return deferred.resolve(); - }) - .catch((err) => { - traceError(`Error while trying to run pytest discovery, \n${err}\r\n\r\n`); - this.testServer.deleteUUID(uuid); - return deferred.reject(err); - }); - return deferred.promise; + const deferredExec = createDeferred>(); + const execArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs); + const result = execService?.execObservable(execArgs, spawnOptions); + + // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. + // Displays output to user and ensure the subprocess doesn't run into buffer overflow. + result?.proc?.stdout?.on('data', (data) => { + spawnOptions.outputChannel?.append(data.toString()); + }); + result?.proc?.stderr?.on('data', (data) => { + spawnOptions.outputChannel?.append(data.toString()); + }); + result?.proc?.on('exit', () => { + deferredExec.resolve({ stdout: '', stderr: '' }); + deferred.resolve(); + }); + + await deferredExec.promise; } } diff --git a/extensions/positron-python/src/client/testing/testController/pytest/pytestExecutionAdapter.ts b/extensions/positron-python/src/client/testing/testController/pytest/pytestExecutionAdapter.ts index a75a6089627..96d53db22c1 100644 --- a/extensions/positron-python/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -15,6 +15,7 @@ import { } from '../common/types'; import { ExecutionFactoryCreateWithEnvironmentOptions, + ExecutionResult, IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; @@ -22,13 +23,7 @@ import { removePositionalFoldersAndFiles } from './arguments'; import { ITestDebugLauncher, LaunchOptions } from '../../common/types'; import { PYTEST_PROVIDER } from '../../common/constants'; import { EXTENSION_ROOT_DIR } from '../../../common/constants'; -import { startTestIdServer } from '../common/utils'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// (global as any).EXTENSION_ROOT_DIR = EXTENSION_ROOT_DIR; -/** - * Wrapper Class for pytest test execution.. - */ +import * as utils from '../common/utils'; export class PytestTestExecutionAdapter implements ITestExecutionAdapter { constructor( @@ -48,18 +43,29 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { ): Promise { const uuid = this.testServer.createUUID(uri.fsPath); traceVerbose(uri, testIds, debugBool); - const disposable = this.testServer.onRunDataReceived((e: DataReceivedEvent) => { + const dataReceivedDisposable = this.testServer.onRunDataReceived((e: DataReceivedEvent) => { if (runInstance) { this.resultResolver?.resolveExecution(JSON.parse(e.data), runInstance); } }); - try { - await this.runTestsNew(uri, testIds, uuid, debugBool, executionFactory, debugLauncher); - } finally { - this.testServer.deleteUUID(uuid); - disposable.dispose(); - // confirm with testing that this gets called (it must clean this up) - } + const disposeDataReceiver = function (testServer: ITestServer) { + testServer.deleteUUID(uuid); + dataReceivedDisposable.dispose(); + }; + runInstance?.token.onCancellationRequested(() => { + disposeDataReceiver(this.testServer); + }); + await this.runTestsNew( + uri, + testIds, + uuid, + runInstance, + debugBool, + executionFactory, + debugLauncher, + disposeDataReceiver, + ); + // placeholder until after the rewrite is adopted // TODO: remove after adoption. const executionPayload: ExecutionTestPayload = { @@ -74,9 +80,11 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { uri: Uri, testIds: string[], uuid: string, + runInstance?: TestRun, debugBool?: boolean, executionFactory?: IPythonExecutionFactory, debugLauncher?: ITestDebugLauncher, + disposeDataReceiver?: (testServer: ITestServer) => void, ): Promise { const deferred = createDeferred(); const relativePathToPytest = 'pythonFiles'; @@ -124,7 +132,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { } traceLog(`Running PYTEST execution for the following test ids: ${testIds}`); - const pytestRunTestIdsPort = await startTestIdServer(testIds); + const pytestRunTestIdsPort = await utils.startTestIdServer(testIds); if (spawnOptions.extraVariables) spawnOptions.extraVariables.RUN_TEST_IDS_PORT = pytestRunTestIdsPort.toString(); @@ -143,6 +151,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { traceInfo(`Running DEBUG pytest with arguments: ${testArgs.join(' ')}\r\n`); await debugLauncher!.launchDebugger(launchOptions, () => { deferred.resolve(); + this.testServer.deleteUUID(uuid); }); } else { // combine path to run script with run args @@ -150,7 +159,28 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { const runArgs = [scriptPath, ...testArgs]; traceInfo(`Running pytests with arguments: ${runArgs.join(' ')}\r\n`); - await execService?.exec(runArgs, spawnOptions); + const deferredExec = createDeferred>(); + const result = execService?.execObservable(runArgs, spawnOptions); + + runInstance?.token.onCancellationRequested(() => { + result?.proc?.kill(); + }); + + // Take all output from the subprocess and add it to the test output channel. This will be the pytest output. + // Displays output to user and ensure the subprocess doesn't run into buffer overflow. + result?.proc?.stdout?.on('data', (data) => { + this.outputChannel?.append(data.toString()); + }); + result?.proc?.stderr?.on('data', (data) => { + this.outputChannel?.append(data.toString()); + }); + + result?.proc?.on('exit', () => { + deferredExec.resolve({ stdout: '', stderr: '' }); + deferred.resolve(); + disposeDataReceiver?.(this.testServer); + }); + await deferredExec.promise; } } catch (ex) { traceError(`Error while running tests: ${testIds}\r\n${ex}\r\n\r\n`); diff --git a/extensions/positron-python/src/client/testing/testController/unittest/testDiscoveryAdapter.ts b/extensions/positron-python/src/client/testing/testController/unittest/testDiscoveryAdapter.ts index 6deca55117e..1cbad7ef65e 100644 --- a/extensions/positron-python/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -43,15 +43,17 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { outChannel: this.outputChannel, }; - const disposable = this.testServer.onDiscoveryDataReceived((e: DataReceivedEvent) => { + const dataReceivedDisposable = this.testServer.onDiscoveryDataReceived((e: DataReceivedEvent) => { this.resultResolver?.resolveDiscovery(JSON.parse(e.data)); }); - try { - await this.callSendCommand(options); - } finally { - this.testServer.deleteUUID(uuid); - disposable.dispose(); - } + const disposeDataReceiver = function (testServer: ITestServer) { + testServer.deleteUUID(uuid); + dataReceivedDisposable.dispose(); + }; + + await this.callSendCommand(options, () => { + disposeDataReceiver(this.testServer); + }); // placeholder until after the rewrite is adopted // TODO: remove after adoption. const discoveryPayload: DiscoveredTestPayload = { @@ -61,8 +63,8 @@ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { return discoveryPayload; } - private async callSendCommand(options: TestCommandOptions): Promise { - await this.testServer.sendCommand(options); + private async callSendCommand(options: TestCommandOptions, callback: () => void): Promise { + await this.testServer.sendCommand(options, undefined, undefined, callback); const discoveryPayload: DiscoveredTestPayload = { cwd: '', status: 'success' }; return discoveryPayload; } diff --git a/extensions/positron-python/src/client/testing/testController/unittest/testExecutionAdapter.ts b/extensions/positron-python/src/client/testing/testController/unittest/testExecutionAdapter.ts index 4cab941c260..9af9e593c24 100644 --- a/extensions/positron-python/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -37,18 +37,19 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { runInstance?: TestRun, ): Promise { const uuid = this.testServer.createUUID(uri.fsPath); - const disposable = this.testServer.onRunDataReceived((e: DataReceivedEvent) => { + const disposedDataReceived = this.testServer.onRunDataReceived((e: DataReceivedEvent) => { if (runInstance) { this.resultResolver?.resolveExecution(JSON.parse(e.data), runInstance); } }); - try { - await this.runTestsNew(uri, testIds, uuid, debugBool); - } finally { - this.testServer.deleteUUID(uuid); - disposable.dispose(); - // confirm with testing that this gets called (it must clean this up) - } + const disposeDataReceiver = function (testServer: ITestServer) { + testServer.deleteUUID(uuid); + disposedDataReceived.dispose(); + }; + runInstance?.token.onCancellationRequested(() => { + disposeDataReceiver(this.testServer); + }); + await this.runTestsNew(uri, testIds, uuid, runInstance, debugBool, disposeDataReceiver); const executionPayload: ExecutionTestPayload = { cwd: uri.fsPath, status: 'success', error: '' }; return executionPayload; } @@ -57,7 +58,9 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { uri: Uri, testIds: string[], uuid: string, + runInstance?: TestRun, debugBool?: boolean, + disposeDataReceiver?: (testServer: ITestServer) => void, ): Promise { const settings = this.configSettings.getSettings(uri); const { unittestArgs } = settings.testing; @@ -80,8 +83,9 @@ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { const runTestIdsPort = await startTestIdServer(testIds); - await this.testServer.sendCommand(options, runTestIdsPort.toString(), () => { + await this.testServer.sendCommand(options, runTestIdsPort.toString(), runInstance, () => { deferred.resolve(); + disposeDataReceiver?.(this.testServer); }); // placeholder until after the rewrite is adopted // TODO: remove after adoption. diff --git a/extensions/positron-python/src/test/activation/extensionSurvey.unit.test.ts b/extensions/positron-python/src/test/activation/extensionSurvey.unit.test.ts index 6449eae24f3..ba96b917aff 100644 --- a/extensions/positron-python/src/test/activation/extensionSurvey.unit.test.ts +++ b/extensions/positron-python/src/test/activation/extensionSurvey.unit.test.ts @@ -355,7 +355,7 @@ suite('Extension survey prompt - showSurvey()', () => { platformService.verifyAll(); }); - test("Disable prompt if 'Do not show again' option is clicked", async () => { + test('Disable prompt if "Don\'t show again" option is clicked', async () => { const prompts = [ExtensionSurveyBanner.bannerLabelYes, ExtensionSurveyBanner.maybeLater, Common.doNotShowAgain]; platformService.setup((p) => p.osType).verifiable(TypeMoq.Times.never()); appShell diff --git a/extensions/positron-python/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts b/extensions/positron-python/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts index 256e57a5d72..32d9198ef7b 100644 --- a/extensions/positron-python/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts +++ b/extensions/positron-python/src/test/activation/node/lspInteractiveWindowMiddlewareAddon.unit.test.ts @@ -10,13 +10,8 @@ import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; import { LanguageClient } from 'vscode-languageclient/node'; import { LspInteractiveWindowMiddlewareAddon } from '../../../client/activation/node/lspInteractiveWindowMiddlewareAddon'; import { JupyterExtensionIntegration } from '../../../client/jupyter/jupyterIntegration'; -import { IExtensions, IInstaller } from '../../../client/common/types'; -import { - IComponentAdapter, - ICondaService, - IInterpreterDisplay, - IInterpreterService, -} from '../../../client/interpreter/contracts'; +import { IExtensions } from '../../../client/common/types'; +import { ICondaService, IInterpreterDisplay } from '../../../client/interpreter/contracts'; import { IInterpreterSelector } from '../../../client/interpreter/configuration/types'; import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; import { IContextKeyManager, IWorkspaceService } from '../../../client/common/application/types'; @@ -32,13 +27,10 @@ suite('Pylance Language Server - Interactive Window LSP Notebooks', () => { languageClient = instance(languageClientMock); jupyterApi = new JupyterExtensionIntegration( mock(), - mock(), mock(), - mock(), mock(), new MockMemento(), mock(), - mock(), mock(), mock(), mock(), diff --git a/extensions/positron-python/src/test/application/diagnostics/checks/macPythonInterpreter.unit.test.ts b/extensions/positron-python/src/test/application/diagnostics/checks/macPythonInterpreter.unit.test.ts index f75375520ec..ba2436d0ffe 100644 --- a/extensions/positron-python/src/test/application/diagnostics/checks/macPythonInterpreter.unit.test.ts +++ b/extensions/positron-python/src/test/application/diagnostics/checks/macPythonInterpreter.unit.test.ts @@ -220,7 +220,7 @@ suite('Application Diagnostics - Checks Mac Python Interpreter', () => { expect(messagePrompt).not.be.equal(undefined, 'Message prompt not set'); expect(messagePrompt!.commandPrompts).to.be.deep.equal([ { prompt: 'Select Python Interpreter', command: cmd }, - { prompt: 'Do not show again', command: cmdIgnore }, + { prompt: "Don't show again", command: cmdIgnore }, ]); }); test('Should not display a message if No Interpreters diagnostic has been ignored', async () => { diff --git a/extensions/positron-python/src/test/common/configuration/service.test.ts b/extensions/positron-python/src/test/common/configuration/service.test.ts index ff47500db73..c57617b2a61 100644 --- a/extensions/positron-python/src/test/common/configuration/service.test.ts +++ b/extensions/positron-python/src/test/common/configuration/service.test.ts @@ -2,8 +2,7 @@ // Licensed under the MIT License. import { expect } from 'chai'; import { workspace } from 'vscode'; -import { IConfigurationService, IDisposableRegistry } from '../../../client/common/types'; -import { disposeAll } from '../../../client/common/utils/resourceLifecycle'; +import { IConfigurationService, IDisposableRegistry, IExtensionContext } from '../../../client/common/types'; import { IServiceContainer } from '../../../client/ioc/types'; import { getExtensionSettings } from '../../extensionSettings'; import { initialize } from '../../initialize'; @@ -23,15 +22,17 @@ suite('Configuration Service', () => { test('Ensure async registry works', async () => { const asyncRegistry = serviceContainer.get(IDisposableRegistry); - let disposed = false; + let subs = serviceContainer.get(IExtensionContext).subscriptions; + const oldLength = subs.length; const disposable = { dispose(): Promise { - disposed = true; return Promise.resolve(); }, }; asyncRegistry.push(disposable); - await disposeAll(asyncRegistry); - expect(disposed).to.be.equal(true, "Didn't dispose during async registry cleanup"); + subs = serviceContainer.get(IExtensionContext).subscriptions; + const newLength = subs.length; + expect(newLength).to.be.equal(oldLength + 1, 'Subscription not added'); + // serviceContainer subscriptions are not disposed of as this breaks other tests that use the service container. }); }); diff --git a/extensions/positron-python/src/test/common/installer/installer.unit.test.ts b/extensions/positron-python/src/test/common/installer/installer.unit.test.ts index bdd7ab32a02..38b9d917447 100644 --- a/extensions/positron-python/src/test/common/installer/installer.unit.test.ts +++ b/extensions/positron-python/src/test/common/installer/installer.unit.test.ts @@ -307,10 +307,10 @@ suite('Module Installer only', () => { TypeMoq.It.isAnyString(), TypeMoq.It.isValue('Install'), TypeMoq.It.isValue('Select Linter'), - TypeMoq.It.isValue('Do not show again'), + TypeMoq.It.isValue("Don't show again"), ), ) - .returns(async () => 'Do not show again') + .returns(async () => "Don't show again") .verifiable(TypeMoq.Times.once()); const persistVal = TypeMoq.Mock.ofType>(); let mockPersistVal = false; @@ -367,7 +367,7 @@ suite('Module Installer only', () => { TypeMoq.It.isAnyString(), TypeMoq.It.isValue('Install'), TypeMoq.It.isValue('Select Linter'), - TypeMoq.It.isValue('Do not show again'), + TypeMoq.It.isValue("Don't show again"), ), ) .returns(async () => undefined) @@ -864,7 +864,7 @@ suite('Module Installer only', () => { test('Ensure 3 options for pylint', async () => { const product = Product.pylint; - const options = ['Select Linter', 'Do not show again']; + const options = ['Select Linter', "Don't show again"]; const productName = ProductNames.get(product)!; await installer.promptToInstallImplementation(product, resource); @@ -875,7 +875,7 @@ suite('Module Installer only', () => { }); test('Ensure select linter command is invoked', async () => { const product = Product.pylint; - const options = ['Select Linter', 'Do not show again']; + const options = ['Select Linter', "Don't show again"]; const productName = ProductNames.get(product)!; when( appShell.showErrorMessage(`Linter ${productName} is not installed.`, 'Install', options[0], options[1]), @@ -892,7 +892,7 @@ suite('Module Installer only', () => { }); test('If install button is selected, install linter and return response', async () => { const product = Product.pylint; - const options = ['Select Linter', 'Do not show again']; + const options = ['Select Linter', "Don't show again"]; const productName = ProductNames.get(product)!; when( appShell.showErrorMessage(`Linter ${productName} is not installed.`, 'Install', options[0], options[1]), diff --git a/extensions/positron-python/src/test/common/installer/productInstaller.unit.test.ts b/extensions/positron-python/src/test/common/installer/productInstaller.unit.test.ts index ed1be158c0a..a63ce23aa43 100644 --- a/extensions/positron-python/src/test/common/installer/productInstaller.unit.test.ts +++ b/extensions/positron-python/src/test/common/installer/productInstaller.unit.test.ts @@ -57,32 +57,6 @@ suite('DataScienceInstaller install', async () => { // noop }); - test('Requires interpreter Uri', async () => { - let threwUp = false; - try { - await dataScienceInstaller.install(Product.ipykernel); - } catch (ex) { - threwUp = true; - } - expect(threwUp).to.equal(true, 'Should raise exception'); - }); - - test('Will ignore with no installer modules', async () => { - const testEnvironment: PythonEnvironment = { - envType: EnvironmentType.VirtualEnv, - envName: 'test', - envPath: interpreterPath, - path: interpreterPath, - architecture: Architecture.x64, - sysPrefix: '', - }; - installationChannelManager - .setup((c) => c.getInstallationChannels(TypeMoq.It.isAny())) - .returns(() => Promise.resolve([])); - const result = await dataScienceInstaller.install(Product.ipykernel, testEnvironment); - expect(result).to.equal(InstallerResponse.Ignore, 'Should be InstallerResponse.Ignore'); - }); - test('Will invoke conda for conda environments', async () => { const testEnvironment: PythonEnvironment = { envType: EnvironmentType.Conda, @@ -144,9 +118,10 @@ suite('DataScienceInstaller install', async () => { expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed'); }); - test('Will invoke poetry', async () => { + test('Will invoke pip for pytorch with conda environment', async () => { + // See https://github.com/microsoft/vscode-jupyter/issues/5034 const testEnvironment: PythonEnvironment = { - envType: EnvironmentType.Poetry, + envType: EnvironmentType.Conda, envName: 'test', envPath: interpreterPath, path: interpreterPath, @@ -155,11 +130,11 @@ suite('DataScienceInstaller install', async () => { }; const testInstaller = TypeMoq.Mock.ofType(); - testInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Poetry); + testInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Pip); testInstaller .setup((c) => c.installModule( - TypeMoq.It.isValue(Product.ipykernel), + TypeMoq.It.isValue(Product.torchProfilerInstallName), TypeMoq.It.isValue(testEnvironment), TypeMoq.It.isAny(), TypeMoq.It.isAny(), @@ -171,13 +146,13 @@ suite('DataScienceInstaller install', async () => { .setup((c) => c.getInstallationChannels(TypeMoq.It.isAny())) .returns(() => Promise.resolve([testInstaller.object])); - const result = await dataScienceInstaller.install(Product.ipykernel, testEnvironment); + const result = await dataScienceInstaller.install(Product.torchProfilerInstallName, testEnvironment); expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed'); }); - test('Will invoke pipenv', async () => { + test('Will invoke poetry', async () => { const testEnvironment: PythonEnvironment = { - envType: EnvironmentType.Pipenv, + envType: EnvironmentType.Poetry, envName: 'test', envPath: interpreterPath, path: interpreterPath, @@ -186,7 +161,7 @@ suite('DataScienceInstaller install', async () => { }; const testInstaller = TypeMoq.Mock.ofType(); - testInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Pipenv); + testInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Poetry); testInstaller .setup((c) => c.installModule( @@ -206,10 +181,9 @@ suite('DataScienceInstaller install', async () => { expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed'); }); - test('Will invoke pip for pytorch with conda environment', async () => { - // See https://github.com/microsoft/vscode-jupyter/issues/5034 + test('Will invoke pipenv', async () => { const testEnvironment: PythonEnvironment = { - envType: EnvironmentType.Conda, + envType: EnvironmentType.Pipenv, envName: 'test', envPath: interpreterPath, path: interpreterPath, @@ -218,11 +192,11 @@ suite('DataScienceInstaller install', async () => { }; const testInstaller = TypeMoq.Mock.ofType(); - testInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Pip); + testInstaller.setup((c) => c.type).returns(() => ModuleInstallerType.Pipenv); testInstaller .setup((c) => c.installModule( - TypeMoq.It.isValue(Product.torchProfilerInstallName), + TypeMoq.It.isValue(Product.ipykernel), TypeMoq.It.isValue(testEnvironment), TypeMoq.It.isAny(), TypeMoq.It.isAny(), @@ -234,9 +208,10 @@ suite('DataScienceInstaller install', async () => { .setup((c) => c.getInstallationChannels(TypeMoq.It.isAny())) .returns(() => Promise.resolve([testInstaller.object])); - const result = await dataScienceInstaller.install(Product.torchProfilerInstallName, testEnvironment); + const result = await dataScienceInstaller.install(Product.ipykernel, testEnvironment); expect(result).to.equal(InstallerResponse.Installed, 'Should be Installed'); }); + }); suite('Formatter installer', async () => { diff --git a/extensions/positron-python/src/test/common/interpreterPathService.unit.test.ts b/extensions/positron-python/src/test/common/interpreterPathService.unit.test.ts index 6ba63d9d663..58a34b3cbcd 100644 --- a/extensions/positron-python/src/test/common/interpreterPathService.unit.test.ts +++ b/extensions/positron-python/src/test/common/interpreterPathService.unit.test.ts @@ -15,7 +15,11 @@ import { WorkspaceConfiguration, } from 'vscode'; import { IApplicationEnvironment, IWorkspaceService } from '../../client/common/application/types'; -import { defaultInterpreterPathSetting, InterpreterPathService } from '../../client/common/interpreterPathService'; +import { + defaultInterpreterPathSetting, + getCIPythonPath, + InterpreterPathService, +} from '../../client/common/interpreterPathService'; import { FileSystemPaths } from '../../client/common/platform/fs-paths'; import { InterpreterConfigurationScope, IPersistentState, IPersistentStateFactory } from '../../client/common/types'; import { createDeferred, sleep } from '../../client/common/utils/async'; @@ -447,7 +451,8 @@ suite('Interpreter Path Service', async () => { workspaceValue: undefined, }); const settingValue = interpreterPathService.get(resource); - expect(settingValue).to.equal('python'); + + expect(settingValue).to.equal(getCIPythonPath()); }); test('If defaultInterpreterPathSetting is changed, an event is fired', async () => { diff --git a/extensions/positron-python/src/test/common/terminals/factory.unit.test.ts b/extensions/positron-python/src/test/common/terminals/factory.unit.test.ts index ef6b7d8f5b0..5ad2da8e793 100644 --- a/extensions/positron-python/src/test/common/terminals/factory.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/factory.unit.test.ts @@ -105,7 +105,7 @@ suite('Terminal Service Factory', () => { expect(notSameAsThirdInstance).to.not.equal(true, 'Instances are the same'); }); - test('Ensure same terminal is returned when using resources from the same workspace', () => { + test('Ensure same terminal is returned when using different resources from the same workspace', () => { const file1A = Uri.file('1a'); const file2A = Uri.file('2a'); const fileB = Uri.file('b'); @@ -140,4 +140,49 @@ suite('Terminal Service Factory', () => { 'Instances should be different for different workspaces', ); }); + + test('When `newTerminalPerFile` is true, ensure different terminal is returned when using different resources from the same workspace', () => { + const file1A = Uri.file('1a'); + const file2A = Uri.file('2a'); + const fileB = Uri.file('b'); + const workspaceUriA = Uri.file('A'); + const workspaceUriB = Uri.file('B'); + const workspaceFolderA = TypeMoq.Mock.ofType(); + workspaceFolderA.setup((w) => w.uri).returns(() => workspaceUriA); + const workspaceFolderB = TypeMoq.Mock.ofType(); + workspaceFolderB.setup((w) => w.uri).returns(() => workspaceUriB); + + workspaceService + .setup((w) => w.getWorkspaceFolder(TypeMoq.It.isValue(file1A))) + .returns(() => workspaceFolderA.object); + workspaceService + .setup((w) => w.getWorkspaceFolder(TypeMoq.It.isValue(file2A))) + .returns(() => workspaceFolderA.object); + workspaceService + .setup((w) => w.getWorkspaceFolder(TypeMoq.It.isValue(fileB))) + .returns(() => workspaceFolderB.object); + + const terminalForFile1A = factory.getTerminalService({ + resource: file1A, + newTerminalPerFile: true, + }) as SynchronousTerminalService; + const terminalForFile2A = factory.getTerminalService({ + resource: file2A, + newTerminalPerFile: true, + }) as SynchronousTerminalService; + const terminalForFileB = factory.getTerminalService({ + resource: fileB, + newTerminalPerFile: true, + }) as SynchronousTerminalService; + + const terminalsAreSameForWorkspaceA = terminalForFile1A.terminalService === terminalForFile2A.terminalService; + expect(terminalsAreSameForWorkspaceA).to.equal(false, 'Instances are the same for Workspace A'); + + const terminalsForWorkspaceABAreDifferent = + terminalForFile1A.terminalService === terminalForFileB.terminalService; + expect(terminalsForWorkspaceABAreDifferent).to.equal( + false, + 'Instances should be different for different workspaces', + ); + }); }); diff --git a/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts b/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts new file mode 100644 index 00000000000..baa83c8b11c --- /dev/null +++ b/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionPrompt.unit.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { mock, when, anything, instance, verify, reset } from 'ts-mockito'; +import { EventEmitter, Terminal, Uri, l10n } from 'vscode'; +import { IActiveResourceService, IApplicationShell, ITerminalManager } from '../../../client/common/application/types'; +import { + IConfigurationService, + IExperimentService, + IPersistentState, + IPersistentStateFactory, + IPythonSettings, +} from '../../../client/common/types'; +import { TerminalEnvVarCollectionPrompt } from '../../../client/interpreter/activation/terminalEnvVarCollectionPrompt'; +import { ITerminalEnvVarCollectionService } from '../../../client/interpreter/activation/types'; +import { Common, Interpreters } from '../../../client/common/utils/localize'; +import { TerminalEnvVarActivation } from '../../../client/common/experiments/groups'; +import { sleep } from '../../core'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; + +suite('Terminal Environment Variable Collection Prompt', () => { + let shell: IApplicationShell; + let terminalManager: ITerminalManager; + let experimentService: IExperimentService; + let activeResourceService: IActiveResourceService; + let terminalEnvVarCollectionService: ITerminalEnvVarCollectionService; + let persistentStateFactory: IPersistentStateFactory; + let terminalEnvVarCollectionPrompt: TerminalEnvVarCollectionPrompt; + let terminalEventEmitter: EventEmitter; + let notificationEnabled: IPersistentState; + let configurationService: IConfigurationService; + let interpreterService: IInterpreterService; + const prompts = [Common.doNotShowAgain]; + const envName = 'env'; + const expectedMessage = Interpreters.terminalEnvVarCollectionPrompt.format(`, ${l10n.t('i.e')} "(${envName})"`); + + setup(async () => { + shell = mock(); + terminalManager = mock(); + interpreterService = mock(); + when(interpreterService.getActiveInterpreter(anything())).thenResolve(({ + envName, + } as unknown) as PythonEnvironment); + experimentService = mock(); + activeResourceService = mock(); + persistentStateFactory = mock(); + terminalEnvVarCollectionService = mock(); + configurationService = mock(); + when(configurationService.getSettings(anything())).thenReturn(({ + terminal: { + activateEnvironment: true, + }, + } as unknown) as IPythonSettings); + notificationEnabled = mock>(); + terminalEventEmitter = new EventEmitter(); + when(persistentStateFactory.createGlobalPersistentState(anything(), true)).thenReturn( + instance(notificationEnabled), + ); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(true); + when(terminalManager.onDidOpenTerminal).thenReturn(terminalEventEmitter.event); + terminalEnvVarCollectionPrompt = new TerminalEnvVarCollectionPrompt( + instance(shell), + instance(persistentStateFactory), + instance(terminalManager), + [], + instance(activeResourceService), + instance(terminalEnvVarCollectionService), + instance(configurationService), + instance(interpreterService), + instance(experimentService), + ); + }); + + test('Show notification when a new terminal is opened for which there is no prompt set', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(false); + when(notificationEnabled.value).thenReturn(true); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(shell.showInformationMessage(expectedMessage, ...prompts)).once(); + }); + + test('Do not show notification if automatic terminal activation is turned off', async () => { + reset(configurationService); + when(configurationService.getSettings(anything())).thenReturn(({ + terminal: { + activateEnvironment: false, + }, + } as unknown) as IPythonSettings); + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(false); + when(notificationEnabled.value).thenReturn(true); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(shell.showInformationMessage(expectedMessage, ...prompts)).never(); + }); + + test('When not in experiment, do not show notification for the same', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(false); + when(notificationEnabled.value).thenReturn(true); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + reset(experimentService); + when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(false); + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(shell.showInformationMessage(expectedMessage, ...prompts)).never(); + }); + + test('Do not show notification if notification is disabled', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(false); + when(notificationEnabled.value).thenReturn(false); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(shell.showInformationMessage(expectedMessage, ...prompts)).never(); + }); + + test('Do not show notification when a new terminal is opened for which there is prompt set', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(true); + when(notificationEnabled.value).thenReturn(true); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenResolve(undefined); + + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(shell.showInformationMessage(expectedMessage, ...prompts)).never(); + }); + + test("Disable notification if `Don't show again` is clicked", async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(false); + when(notificationEnabled.value).thenReturn(true); + when(notificationEnabled.updateValue(false)).thenResolve(); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenReturn( + Promise.resolve(Common.doNotShowAgain), + ); + + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(notificationEnabled.updateValue(false)).once(); + }); + + test('Do not disable notification if prompt is closed', async () => { + const resource = Uri.file('a'); + const terminal = ({ + creationOptions: { + cwd: resource, + }, + } as unknown) as Terminal; + when(terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource)).thenReturn(false); + when(notificationEnabled.value).thenReturn(true); + when(notificationEnabled.updateValue(false)).thenResolve(); + when(shell.showInformationMessage(expectedMessage, ...prompts)).thenReturn(Promise.resolve(undefined)); + + await terminalEnvVarCollectionPrompt.activate(); + terminalEventEmitter.fire(terminal); + await sleep(1); + + verify(notificationEnabled.updateValue(false)).never(); + }); +}); diff --git a/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts b/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts index cb0b6b02f28..1513be676ee 100644 --- a/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts +++ b/extensions/positron-python/src/test/interpreters/activation/terminalEnvVarCollectionService.unit.test.ts @@ -5,13 +5,14 @@ import * as sinon from 'sinon'; import { assert, expect } from 'chai'; -import { cloneDeep } from 'lodash'; import { mock, instance, when, anything, verify, reset } from 'ts-mockito'; import { EnvironmentVariableCollection, - EnvironmentVariableScope, + EnvironmentVariableMutatorOptions, + GlobalEnvironmentVariableCollection, ProgressLocation, Uri, + WorkspaceConfiguration, WorkspaceFolder, } from 'vscode'; import { @@ -31,13 +32,12 @@ import { import { Interpreters } from '../../../client/common/utils/localize'; import { OSType, getOSType } from '../../../client/common/utils/platform'; import { defaultShells } from '../../../client/interpreter/activation/service'; -import { - TerminalEnvVarCollectionService, - _normCaseKeys, -} from '../../../client/interpreter/activation/terminalEnvVarCollectionService'; +import { TerminalEnvVarCollectionService } from '../../../client/interpreter/activation/terminalEnvVarCollectionService'; import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; import { IInterpreterService } from '../../../client/interpreter/contracts'; import { PathUtils } from '../../../client/common/platform/pathUtils'; +import { PythonEnvType } from '../../../client/pythonEnvironments/base/info'; +import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; suite('Terminal Environment Variable Collection Service', () => { let platform: IPlatformService; @@ -45,13 +45,12 @@ suite('Terminal Environment Variable Collection Service', () => { let context: IExtensionContext; let shell: IApplicationShell; let experimentService: IExperimentService; - let collection: EnvironmentVariableCollection & { - getScopedEnvironmentVariableCollection(scope: EnvironmentVariableScope): EnvironmentVariableCollection; - }; - let scopedCollection: EnvironmentVariableCollection; + let collection: EnvironmentVariableCollection; + let globalCollection: GlobalEnvironmentVariableCollection; let applicationEnvironment: IApplicationEnvironment; let environmentActivationService: IEnvironmentActivationService; let workspaceService: IWorkspaceService; + let workspaceConfig: WorkspaceConfiguration; let terminalEnvVarCollectionService: TerminalEnvVarCollectionService; const progressOptions = { location: ProgressLocation.Window, @@ -64,21 +63,20 @@ suite('Terminal Environment Variable Collection Service', () => { setup(() => { workspaceService = mock(); + workspaceConfig = mock(); when(workspaceService.getWorkspaceFolder(anything())).thenReturn(undefined); when(workspaceService.workspaceFolders).thenReturn(undefined); + when(workspaceService.getConfiguration('terminal')).thenReturn(instance(workspaceConfig)); + when(workspaceConfig.get('integrated.shellIntegration.enabled')).thenReturn(true); platform = mock(); when(platform.osType).thenReturn(getOSType()); interpreterService = mock(); context = mock(); shell = mock(); - collection = mock< - EnvironmentVariableCollection & { - getScopedEnvironmentVariableCollection(scope: EnvironmentVariableScope): EnvironmentVariableCollection; - } - >(); - scopedCollection = mock(); - when(collection.getScopedEnvironmentVariableCollection(anything())).thenReturn(instance(scopedCollection)); - when(context.environmentVariableCollection).thenReturn(instance(collection)); + globalCollection = mock(); + collection = mock(); + when(context.environmentVariableCollection).thenReturn(instance(globalCollection)); + when(globalCollection.getScoped(anything())).thenReturn(instance(collection)); experimentService = mock(); when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(true); applicationEnvironment = mock(); @@ -89,11 +87,15 @@ suite('Terminal Environment Variable Collection Service', () => { }) .thenResolve(); environmentActivationService = mock(); + when(environmentActivationService.getProcessEnvironmentVariables(anything(), anything())).thenResolve( + process.env, + ); configService = mock(); when(configService.getSettings(anything())).thenReturn(({ terminal: { activateEnvironment: true }, pythonPath: displayPath, } as unknown) as IPythonSettings); + when(collection.clear()).thenResolve(); terminalEnvVarCollectionService = new TerminalEnvVarCollectionService( instance(platform), instance(interpreterService), @@ -124,6 +126,7 @@ suite('Terminal Environment Variable Collection Service', () => { test('When not in experiment, do not apply activated variables to the collection and clear it instead', async () => { reset(experimentService); + when(context.environmentVariableCollection).thenReturn(instance(collection)); when(experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)).thenReturn(false); const applyCollectionStub = sinon.stub(terminalEnvVarCollectionService, '_applyCollection'); applyCollectionStub.resolves(); @@ -169,8 +172,27 @@ suite('Terminal Environment Variable Collection Service', () => { }); test('If activated variables are returned for custom shell, apply it correctly to the collection', async () => { - const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; - delete envVars.PATH; + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.clear()).once(); + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); + }); + + test('If activated variables contain PS1, prefix it using shell integration', async () => { + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env, PS1: '(prompt)' }; when( environmentActivationService.getActivatedEnvironmentVariables( anything(), @@ -182,16 +204,84 @@ suite('Terminal Environment Variable Collection Service', () => { when(collection.replace(anything(), anything(), anything())).thenResolve(); when(collection.delete(anything())).thenResolve(); + let opts: EnvironmentVariableMutatorOptions | undefined; + when(collection.prepend('PS1', '(prompt)', anything())).thenCall((_, _v, o) => { + opts = o; + }); await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + verify(collection.clear()).once(); verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); - verify(collection.delete('PATH')).once(); + assert.deepEqual(opts, { applyAtProcessCreation: false, applyAtShellIntegration: true }); + }); + + test('Prepend only "prepend portion of PATH" where applicable', async () => { + const processEnv = { PATH: 'hello/1/2/3' }; + reset(environmentActivationService); + when(environmentActivationService.getProcessEnvironmentVariables(anything(), anything())).thenResolve( + processEnv, + ); + const prependedPart = 'path/to/activate/dir:'; + const envVars: NodeJS.ProcessEnv = { PATH: `${prependedPart}${processEnv.PATH}` }; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + let opts: EnvironmentVariableMutatorOptions | undefined; + when(collection.prepend('PATH', anything(), anything())).thenCall((_, _v, o) => { + opts = o; + }); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.clear()).once(); + verify(collection.prepend('PATH', prependedPart, anything())).once(); + verify(collection.replace('PATH', anything(), anything())).never(); + assert.deepEqual(opts, { applyAtProcessCreation: true, applyAtShellIntegration: true }); + }); + + test('Prepend full PATH otherwise', async () => { + const processEnv = { PATH: 'hello/1/2/3' }; + reset(environmentActivationService); + when(environmentActivationService.getProcessEnvironmentVariables(anything(), anything())).thenResolve( + processEnv, + ); + const finalPath = 'hello/3/2/1'; + const envVars: NodeJS.ProcessEnv = { PATH: finalPath }; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything())).thenResolve(); + let opts: EnvironmentVariableMutatorOptions | undefined; + when(collection.prepend('PATH', anything(), anything())).thenCall((_, _v, o) => { + opts = o; + }); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.clear()).once(); + verify(collection.prepend('PATH', finalPath, anything())).once(); + verify(collection.replace('PATH', anything(), anything())).never(); + assert.deepEqual(opts, { applyAtProcessCreation: true, applyAtShellIntegration: true }); }); test('Verify envs are not applied if env activation is disabled', async () => { - const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; - delete envVars.PATH; + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; when( environmentActivationService.getActivatedEnvironmentVariables( anything(), @@ -211,13 +301,12 @@ suite('Terminal Environment Variable Collection Service', () => { await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + verify(collection.clear()).once(); verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).never(); - verify(collection.delete('PATH')).never(); }); test('Verify correct options are used when applying envs and setting description', async () => { - const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; - delete envVars.PATH; + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; const resource = Uri.file('a'); const workspaceFolder: WorkspaceFolder = { uri: Uri.file('workspacePath'), @@ -229,55 +318,232 @@ suite('Terminal Environment Variable Collection Service', () => { environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, customShell), ).thenResolve(envVars); - when(scopedCollection.replace(anything(), anything(), anything())).thenCall((_e, _v, options) => { - assert.deepEqual(options, { applyAtShellIntegration: true }); - return Promise.resolve(); - }); - when(scopedCollection.delete(anything())).thenResolve(); + when(collection.replace(anything(), anything(), anything())).thenCall( + (_e, _v, options: EnvironmentVariableMutatorOptions) => { + assert.deepEqual(options, { applyAtShellIntegration: true, applyAtProcessCreation: true }); + return Promise.resolve(); + }, + ); await terminalEnvVarCollectionService._applyCollection(resource, customShell); - verify(scopedCollection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); - verify(scopedCollection.delete('PATH')).once(); + verify(collection.clear()).once(); + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); }); - test('Only relative changes to previously applied variables are applied to the collection', async () => { - const envVars: NodeJS.ProcessEnv = { - RANDOM_VAR: 'random', - CONDA_PREFIX: 'prefix/to/conda', - ..._normCaseKeys(process.env), + test('Correct track that prompt was set for non-Windows bash where PS1 is set', async () => { + when(platform.osType).thenReturn(OSType.Linux); + const envVars: NodeJS.ProcessEnv = { VIRTUAL_ENV: 'prefix/to/venv', PS1: '(.venv)', ...process.env }; + const ps1Shell = 'bash'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); when( - environmentActivationService.getActivatedEnvironmentVariables( - anything(), - undefined, - undefined, - customShell, - ), + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, ps1Shell), ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); - when(collection.replace(anything(), anything(), anything())).thenResolve(); - when(collection.delete(anything())).thenResolve(); + await terminalEnvVarCollectionService._applyCollection(resource, ps1Shell); - await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); - const newEnvVars = cloneDeep(envVars); - delete newEnvVars.CONDA_PREFIX; - newEnvVars.RANDOM_VAR = undefined; // Deleting the variable from the collection is the same as setting it to undefined. - reset(environmentActivationService); + expect(result).to.equal(true); + }); + + test('Correct track that prompt was set for PS1 if shell integration is disabled', async () => { + reset(workspaceConfig); + when(workspaceConfig.get('integrated.shellIntegration.enabled')).thenReturn(false); + when(platform.osType).thenReturn(OSType.Linux); + const envVars: NodeJS.ProcessEnv = { VIRTUAL_ENV: 'prefix/to/venv', PS1: '(.venv)', ...process.env }; + const ps1Shell = 'bash'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); when( - environmentActivationService.getActivatedEnvironmentVariables( - anything(), - undefined, - undefined, - customShell, - ), - ).thenResolve(newEnvVars); + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, ps1Shell), + ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); - await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + await terminalEnvVarCollectionService._applyCollection(resource, ps1Shell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); + + expect(result).to.equal(false); + }); + + test('Correct track that prompt was set for non-Windows where PS1 is not set but should be set', async () => { + when(platform.osType).thenReturn(OSType.Linux); + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; + const ps1Shell = 'zsh'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Conda, + envName: 'envName', + envPath: 'prefix/to/conda', + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, ps1Shell), + ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService._applyCollection(resource, ps1Shell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); + + expect(result).to.equal(true); + }); + + test('Correct track that prompt was not set for non-Windows where PS1 is not set but env name is base', async () => { + when(platform.osType).thenReturn(OSType.Linux); + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; + const ps1Shell = 'zsh'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Conda, + envName: 'base', + envPath: 'prefix/to/conda', + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, ps1Shell), + ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService._applyCollection(resource, ps1Shell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); + + expect(result).to.equal(false); + }); + + test('Correct track that prompt was not set for non-Windows fish where PS1 is not set', async () => { + when(platform.osType).thenReturn(OSType.Linux); + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; + const ps1Shell = 'fish'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Conda, + envName: 'envName', + envPath: 'prefix/to/conda', + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, ps1Shell), + ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService._applyCollection(resource, ps1Shell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); + + expect(result).to.equal(false); + }); + + test('Correct track that prompt was set correctly for global interpreters', async () => { + when(platform.osType).thenReturn(OSType.Linux); + const ps1Shell = 'zsh'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: undefined, + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, ps1Shell), + ).thenResolve(undefined); + when(collection.replace(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService._applyCollection(resource, ps1Shell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); - verify(collection.delete('CONDA_PREFIX')).once(); - verify(collection.delete('RANDOM_VAR')).once(); + expect(result).to.equal(true); + }); + + test('Correct track that prompt was set for Windows when not using powershell', async () => { + when(platform.osType).thenReturn(OSType.Windows); + const envVars: NodeJS.ProcessEnv = { VIRTUAL_ENV: 'prefix/to/venv', ...process.env }; + const windowsShell = 'cmd'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, windowsShell), + ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService._applyCollection(resource, windowsShell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); + + expect(result).to.equal(true); + }); + + test('Correct track that prompt was not set for Windows when using powershell', async () => { + when(platform.osType).thenReturn(OSType.Linux); + const envVars: NodeJS.ProcessEnv = { VIRTUAL_ENV: 'prefix/to/venv', ...process.env }; + const windowsShell = 'powershell'; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(interpreterService.getActiveInterpreter(resource)).thenResolve(({ + type: PythonEnvType.Virtual, + } as unknown) as PythonEnvironment); + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, windowsShell), + ).thenResolve(envVars); + when(collection.replace(anything(), anything(), anything())).thenReturn(); + + await terminalEnvVarCollectionService._applyCollection(resource, windowsShell); + + const result = terminalEnvVarCollectionService.isTerminalPromptSetCorrectly(resource); + + expect(result).to.equal(false); }); test('If no activated variables are returned for custom shell, fallback to using default shell', async () => { @@ -289,7 +555,7 @@ suite('Terminal Environment Variable Collection Service', () => { customShell, ), ).thenResolve(undefined); - const envVars = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; + const envVars = { CONDA_PREFIX: 'prefix/to/conda', ...process.env }; when( environmentActivationService.getActivatedEnvironmentVariables( anything(), @@ -300,12 +566,11 @@ suite('Terminal Environment Variable Collection Service', () => { ).thenResolve(envVars); when(collection.replace(anything(), anything(), anything())).thenResolve(); - when(collection.delete(anything())).thenResolve(); await terminalEnvVarCollectionService._applyCollection(undefined, customShell); verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); - verify(collection.delete(anything())).never(); + verify(collection.clear()).twice(); }); test('If no activated variables are returned for default shell, clear collection', async () => { diff --git a/extensions/positron-python/src/test/interpreters/virtualEnvs/virtualEnvPrompt.unit.test.ts b/extensions/positron-python/src/test/interpreters/virtualEnvs/virtualEnvPrompt.unit.test.ts index 9671e393dc4..2ad67831c45 100644 --- a/extensions/positron-python/src/test/interpreters/virtualEnvs/virtualEnvPrompt.unit.test.ts +++ b/extensions/positron-python/src/test/interpreters/virtualEnvs/virtualEnvPrompt.unit.test.ts @@ -223,7 +223,7 @@ suite('Virtual Environment Prompt', () => { notificationPromptEnabled.verifyAll(); }); - test("If user selects 'Do not show again', prompt is disabled", async () => { + test('If user selects "Don\'t show again", prompt is disabled', async () => { const resource = Uri.file('a'); const interpreter1 = { path: 'path/to/interpreter1' }; const prompts = [Common.bannerLabelYes, Common.bannerLabelNo, Common.doNotShowAgain]; diff --git a/extensions/positron-python/src/test/linters/lint.functional.test.ts b/extensions/positron-python/src/test/linters/lint.functional.test.ts index a3dc70b7c21..9887cbc5605 100644 --- a/extensions/positron-python/src/test/linters/lint.functional.test.ts +++ b/extensions/positron-python/src/test/linters/lint.functional.test.ts @@ -4,7 +4,6 @@ 'use strict'; import * as assert from 'assert'; -import * as childProcess from 'child_process'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; @@ -780,11 +779,6 @@ suite('Linting Functional Tests', () => { teardown(() => { sinon.restore(); }); - - const pythonPath = childProcess.execSync(`"${PYTHON_PATH}" -c "import sys;print(sys.executable)"`); - - console.log(`Testing linter with python ${pythonPath}`); - // These are integration tests that mock out everything except // the filesystem and process execution. diff --git a/extensions/positron-python/src/test/mocks/helper.ts b/extensions/positron-python/src/test/mocks/helper.ts new file mode 100644 index 00000000000..24d7a8290b1 --- /dev/null +++ b/extensions/positron-python/src/test/mocks/helper.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Readable } from 'stream'; + +export class FakeReadableStream extends Readable { + _read(_size: unknown): void | null { + // custom reading logic here + this.push(null); // end the stream + } +} diff --git a/extensions/positron-python/src/test/mocks/mockChildProcess.ts b/extensions/positron-python/src/test/mocks/mockChildProcess.ts new file mode 100644 index 00000000000..a46d66d79ca --- /dev/null +++ b/extensions/positron-python/src/test/mocks/mockChildProcess.ts @@ -0,0 +1,239 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Serializable, SendHandle, MessageOptions } from 'child_process'; +import { EventEmitter } from 'node:events'; +import { Writable, Readable, Pipe } from 'stream'; +import { FakeReadableStream } from './helper'; + +export class MockChildProcess extends EventEmitter { + constructor(spawnfile: string, spawnargs: string[]) { + super(); + this.spawnfile = spawnfile; + this.spawnargs = spawnargs; + this.stdin = new Writable(); + this.stdout = new FakeReadableStream(); + this.stderr = new FakeReadableStream(); + this.channel = null; + this.stdio = [this.stdin, this.stdout, this.stdout, this.stderr, null]; + this.killed = false; + this.connected = false; + this.exitCode = null; + this.signalCode = null; + this.eventMap = new Map(); + } + + stdin: Writable | null; + + stdout: Readable | null; + + stderr: Readable | null; + + eventMap: Map; + + readonly channel?: Pipe | null | undefined; + + readonly stdio: [ + Writable | null, + // stdin + Readable | null, + // stdout + Readable | null, + // stderr + Readable | Writable | null | undefined, + // extra + Readable | Writable | null | undefined, // extra + ]; + + readonly killed: boolean; + + readonly pid?: number | undefined; + + readonly connected: boolean; + + readonly exitCode: number | null; + + readonly signalCode: NodeJS.Signals | null; + + readonly spawnargs: string[]; + + readonly spawnfile: string; + + signal?: NodeJS.Signals | number; + + send(message: Serializable, callback?: (error: Error | null) => void): boolean; + + send(message: Serializable, sendHandle?: SendHandle, callback?: (error: Error | null) => void): boolean; + + send( + message: Serializable, + sendHandle?: SendHandle, + options?: MessageOptions, + callback?: (error: Error | null) => void, + ): boolean; + + send( + message: Serializable, + _sendHandleOrCallback?: SendHandle | ((error: Error | null) => void), + _optionsOrCallback?: MessageOptions | ((error: Error | null) => void), + _callback?: (error: Error | null) => void, + ): boolean { + // Implementation of the send method + // For example, you might want to emit a 'message' event + this.stdout?.push(message.toString()); + return true; + } + + // eslint-disable-next-line class-methods-use-this + disconnect(): void { + /* noop */ + } + + // eslint-disable-next-line class-methods-use-this + unref(): void { + /* noop */ + } + + // eslint-disable-next-line class-methods-use-this + ref(): void { + /* noop */ + } + + addListener(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + addListener(event: 'disconnect', listener: () => void): this; + + addListener(event: 'error', listener: (err: Error) => void): this; + + addListener(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + addListener(event: 'message', listener: (message: Serializable, sendHandle: SendHandle) => void): this; + + addListener(event: 'spawn', listener: () => void): this; + + addListener(event: string, listener: (...args: any[]) => void): this { + if (this.eventMap.has(event)) { + this.eventMap.get(event).push(listener); + } else { + this.eventMap.set(event, [listener]); + } + return this; + } + + emit(event: 'close', code: number | null, signal: NodeJS.Signals | null): boolean; + + emit(event: 'disconnect'): boolean; + + emit(event: 'error', err: Error): boolean; + + emit(event: 'exit', code: number | null, signal: NodeJS.Signals | null): boolean; + + emit(event: 'message', message: Serializable, sendHandle: SendHandle): boolean; + + emit(event: 'spawn', listener: () => void): boolean; + + emit(event: string | symbol, ...args: unknown[]): boolean { + if (this.eventMap.has(event.toString())) { + this.eventMap.get(event.toString()).forEach((listener: (arg0: unknown) => void) => { + const argsArray = Array.isArray(args) ? args : [args]; + listener(argsArray); + }); + } + return true; + } + + on(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + on(event: 'disconnect', listener: () => void): this; + + on(event: 'error', listener: (err: Error) => void): this; + + on(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + on(event: 'message', listener: (message: Serializable, sendHandle: SendHandle) => void): this; + + on(event: 'spawn', listener: () => void): this; + + on(event: string, listener: (...args: any[]) => void): this { + if (this.eventMap.has(event)) { + this.eventMap.get(event).push(listener); + } else { + this.eventMap.set(event, [listener]); + } + return this; + } + + once(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + once(event: 'disconnect', listener: () => void): this; + + once(event: 'error', listener: (err: Error) => void): this; + + once(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + once(event: 'message', listener: (message: Serializable, sendHandle: SendHandle) => void): this; + + once(event: 'spawn', listener: () => void): this; + + once(event: string, listener: (...args: any[]) => void): this { + if (this.eventMap.has(event)) { + this.eventMap.get(event).push(listener); + } else { + this.eventMap.set(event, [listener]); + } + return this; + } + + prependListener(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + prependListener(event: 'disconnect', listener: () => void): this; + + prependListener(event: 'error', listener: (err: Error) => void): this; + + prependListener(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + prependListener(event: 'message', listener: (message: Serializable, sendHandle: SendHandle) => void): this; + + prependListener(event: 'spawn', listener: () => void): this; + + prependListener(event: string, listener: (...args: any[]) => void): this { + if (this.eventMap.has(event)) { + this.eventMap.get(event).push(listener); + } else { + this.eventMap.set(event, [listener]); + } + return this; + } + + prependOnceListener(event: 'close', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + prependOnceListener(event: 'disconnect', listener: () => void): this; + + prependOnceListener(event: 'error', listener: (err: Error) => void): this; + + prependOnceListener(event: 'exit', listener: (code: number | null, signal: NodeJS.Signals | null) => void): this; + + prependOnceListener(event: 'message', listener: (message: Serializable, sendHandle: SendHandle) => void): this; + + prependOnceListener(event: 'spawn', listener: () => void): this; + + prependOnceListener(event: string, listener: (...args: any[]) => void): this { + if (this.eventMap.has(event)) { + this.eventMap.get(event).push(listener); + } else { + this.eventMap.set(event, [listener]); + } + return this; + } + + trigger(event: string): Array { + if (this.eventMap.has(event)) { + return this.eventMap.get(event); + } + return []; + } + + kill(_signal?: NodeJS.Signals | number): boolean { + this.stdout?.destroy(); + return true; + } +} diff --git a/extensions/positron-python/src/test/pythonEnvironments/common/commonUtils.functional.test.ts b/extensions/positron-python/src/test/pythonEnvironments/common/commonUtils.functional.test.ts index e0c1f755e2c..647a17a40a9 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/common/commonUtils.functional.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/common/commonUtils.functional.test.ts @@ -86,7 +86,6 @@ suite('pyenvs common utils - finding Python executables', () => { python3 -> sub2/sub2.2/python3 python3.7 -> sub2/sub2.1/sub2.1.1/python - python2.7 -> does-not-exist `); } }); @@ -106,7 +105,6 @@ suite('pyenvs common utils - finding Python executables', () => { // These will match. 'python', 'python2', - 'python2.7', 'python3', 'python3.7', 'python3.8', @@ -137,7 +135,6 @@ suite('pyenvs common utils - finding Python executables', () => { // These will match. 'python', 'python2', - 'python2.7', 'python3', 'python3.7', 'python3.8', @@ -167,7 +164,6 @@ suite('pyenvs common utils - finding Python executables', () => { // These will match. 'python', 'python2', - 'python2.7', 'python3', 'python3.7', 'python3.8', diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts index de8e263fc3f..4763b54a730 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/common/installCheckUtils.unit.test.ts @@ -5,10 +5,12 @@ 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 { Diagnostic, TextDocument, Range, Uri, WorkspaceConfiguration, ConfigurationScope } from 'vscode'; import * as rawProcessApis from '../../../../client/common/process/rawProcessApis'; import { getInstalledPackagesDiagnostics } from '../../../../client/pythonEnvironments/creation/common/installCheckUtils'; import { IInterpreterPathService } from '../../../../client/common/types'; +import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; +import { SpawnOptions } from '../../../../client/common/process/types'; chaiUse(chaiAsPromised); @@ -37,10 +39,20 @@ const MISSING_PACKAGES: Diagnostic[] = [ suite('Install check diagnostics tests', () => { let plainExecStub: sinon.SinonStub; let interpreterPathService: typemoq.IMock; + let getConfigurationStub: sinon.SinonStub; + let configMock: typemoq.IMock; setup(() => { + configMock = typemoq.Mock.ofType(); plainExecStub = sinon.stub(rawProcessApis, 'plainExec'); interpreterPathService = typemoq.Mock.ofType(); + getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration'); + getConfigurationStub.callsFake((section?: string, _scope?: ConfigurationScope | null) => { + if (section === 'python') { + return configMock.object; + } + return undefined; + }); }); teardown(() => { @@ -48,18 +60,55 @@ suite('Install check diagnostics tests', () => { }); test('Test parse diagnostics', async () => { + configMock + .setup((c) => c.get('missingPackage.severity', 'Hint')) + .returns(() => 'Error') + .verifiable(typemoq.Times.atLeastOnce()); plainExecStub.resolves({ stdout: MISSING_PACKAGES_STR, stderr: '' }); const someFile = getSomeRequirementFile(); const result = await getInstalledPackagesDiagnostics(interpreterPathService.object, someFile.object); assert.deepStrictEqual(result, MISSING_PACKAGES); + configMock.verifyAll(); }); test('Test parse empty diagnostics', async () => { + configMock + .setup((c) => c.get('missingPackage.severity', 'Hint')) + .returns(() => 'Error') + .verifiable(typemoq.Times.atLeastOnce()); plainExecStub.resolves({ stdout: '', stderr: '' }); const someFile = getSomeRequirementFile(); const result = await getInstalledPackagesDiagnostics(interpreterPathService.object, someFile.object); assert.deepStrictEqual(result, []); + configMock.verifyAll(); + }); + + [ + ['Error', '0'], + ['Warning', '1'], + ['Information', '2'], + ['Hint', '3'], + ].forEach((severityType: string[]) => { + const setting = severityType[0]; + const expected = severityType[1]; + test(`Test missing package severity: ${setting}`, async () => { + configMock + .setup((c) => c.get('missingPackage.severity', 'Hint')) + .returns(() => setting) + .verifiable(typemoq.Times.atLeastOnce()); + let severity: string | undefined; + plainExecStub.callsFake((_cmd: string, _args: string[], options: SpawnOptions) => { + severity = options.env?.VSCODE_MISSING_PGK_SEVERITY; + return { stdout: '', stderr: '' }; + }); + const someFile = getSomeRequirementFile(); + const result = await getInstalledPackagesDiagnostics(interpreterPathService.object, someFile.object); + + assert.deepStrictEqual(result, []); + assert.deepStrictEqual(severity, expected); + configMock.verifyAll(); + }); }); }); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts index cb4df95c8c1..e1ac1bafe6a 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/condaCreationProvider.unit.test.ts @@ -134,8 +134,6 @@ suite('Conda Creation provider tests', () => { assert.deepStrictEqual(await promise, { path: 'new_environment', workspaceFolder: workspace1, - action: undefined, - error: undefined, }); assert.isTrue(showErrorMessageWithLogsStub.notCalled); }); @@ -187,7 +185,8 @@ suite('Conda Creation provider tests', () => { assert.isDefined(_error); _error!('bad arguments'); _complete!(); - await assert.isRejected(promise); + const result = await promise; + assert.ok(result?.error); assert.isTrue(showErrorMessageWithLogsStub.calledOnce); }); @@ -243,7 +242,8 @@ suite('Conda Creation provider tests', () => { _next!({ out: `${CONDA_ENV_CREATED_MARKER}new_environment`, source: 'stdout' }); _complete!(); - await assert.isRejected(promise); + const result = await promise; + assert.ok(result?.error); assert.isTrue(showErrorMessageWithLogsStub.calledOnce); }); }); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts index 1c22264f2ad..de65887b7ed 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvCreationProvider.unit.test.ts @@ -35,6 +35,8 @@ suite('venv Creation provider tests', () => { let withProgressStub: sinon.SinonStub; let showErrorMessageWithLogsStub: sinon.SinonStub; let pickPackagesToInstallStub: sinon.SinonStub; + let pickExistingVenvActionStub: sinon.SinonStub; + let deleteEnvironmentStub: sinon.SinonStub; const workspace1 = { uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), @@ -43,6 +45,8 @@ suite('venv Creation provider tests', () => { }; setup(() => { + pickExistingVenvActionStub = sinon.stub(venvUtils, 'pickExistingVenvAction'); + deleteEnvironmentStub = sinon.stub(venvUtils, 'deleteEnvironment'); pickWorkspaceFolderStub = sinon.stub(wsSelect, 'pickWorkspaceFolder'); execObservableStub = sinon.stub(rawProcessApis, 'execObservable'); interpreterQuickPick = typemoq.Mock.ofType(); @@ -54,6 +58,9 @@ suite('venv Creation provider tests', () => { progressMock = typemoq.Mock.ofType(); venvProvider = new VenvCreationProvider(interpreterQuickPick.object); + + pickExistingVenvActionStub.resolves(venvUtils.ExistingVenvAction.Create); + deleteEnvironmentStub.resolves(true); }); teardown(() => { @@ -70,6 +77,8 @@ suite('venv Creation provider tests', () => { assert.isTrue(pickWorkspaceFolderStub.calledOnce); interpreterQuickPick.verifyAll(); assert.isTrue(pickPackagesToInstallStub.notCalled); + assert.isTrue(pickExistingVenvActionStub.notCalled); + assert.isTrue(deleteEnvironmentStub.notCalled); }); test('No Python selected', async () => { @@ -85,6 +94,7 @@ suite('venv Creation provider tests', () => { assert.isTrue(pickWorkspaceFolderStub.calledOnce); interpreterQuickPick.verifyAll(); assert.isTrue(pickPackagesToInstallStub.notCalled); + assert.isTrue(deleteEnvironmentStub.notCalled); }); test('User pressed Esc while selecting dependencies', async () => { @@ -99,6 +109,7 @@ suite('venv Creation provider tests', () => { await assert.isRejected(venvProvider.createEnvironment()); assert.isTrue(pickPackagesToInstallStub.calledOnce); + assert.isTrue(deleteEnvironmentStub.notCalled); }); test('Create venv with python selected by user no packages selected', async () => { @@ -158,12 +169,11 @@ suite('venv Creation provider tests', () => { assert.deepStrictEqual(actual, { path: 'new_environment', workspaceFolder: workspace1, - action: undefined, - error: undefined, }); interpreterQuickPick.verifyAll(); progressMock.verifyAll(); assert.isTrue(showErrorMessageWithLogsStub.notCalled); + assert.isTrue(deleteEnvironmentStub.notCalled); }); test('Create venv failed', async () => { @@ -216,8 +226,10 @@ suite('venv Creation provider tests', () => { assert.isDefined(_error); _error!('bad arguments'); _complete!(); - await assert.isRejected(promise); + const result = await promise; + assert.ok(result?.error); assert.isTrue(showErrorMessageWithLogsStub.calledOnce); + assert.isTrue(deleteEnvironmentStub.notCalled); }); test('Create venv failed (non-zero exit code)', async () => { @@ -272,9 +284,114 @@ suite('venv Creation provider tests', () => { _next!({ out: `${VENV_CREATED_MARKER}new_environment`, source: 'stdout' }); _complete!(); - await assert.isRejected(promise); + const result = await promise; + assert.ok(result?.error); interpreterQuickPick.verifyAll(); progressMock.verifyAll(); assert.isTrue(showErrorMessageWithLogsStub.calledOnce); + assert.isTrue(deleteEnvironmentStub.notCalled); + }); + + test('Create venv with pre-existing .venv, user selects re-create', async () => { + pickExistingVenvActionStub.resolves(venvUtils.ExistingVenvAction.Recreate); + pickWorkspaceFolderStub.resolves(workspace1); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve('/usr/bin/python')) + .verifiable(typemoq.Times.once()); + + pickPackagesToInstallStub.resolves([]); + + const deferred = createDeferred(); + let _next: undefined | ((value: Output) => void); + let _complete: undefined | (() => void); + execObservableStub.callsFake(() => { + deferred.resolve(); + return { + proc: { + exitCode: 0, + }, + out: { + subscribe: ( + next?: (value: Output) => void, + _error?: (error: unknown) => void, + complete?: () => void, + ) => { + _next = next; + _complete = complete; + }, + }, + dispose: () => undefined, + }; + }); + + progressMock.setup((p) => p.report({ message: CreateEnv.statusStarting })).verifiable(typemoq.Times.once()); + + withProgressStub.callsFake( + ( + _options: ProgressOptions, + task: ( + progress: CreateEnvironmentProgress, + token?: CancellationToken, + ) => Thenable, + ) => task(progressMock.object), + ); + + const promise = venvProvider.createEnvironment(); + await deferred.promise; + assert.isDefined(_next); + assert.isDefined(_complete); + + _next!({ out: `${VENV_CREATED_MARKER}new_environment`, source: 'stdout' }); + _complete!(); + + const actual = await promise; + assert.deepStrictEqual(actual, { + path: 'new_environment', + workspaceFolder: workspace1, + }); + interpreterQuickPick.verifyAll(); + progressMock.verifyAll(); + assert.isTrue(showErrorMessageWithLogsStub.notCalled); + assert.isTrue(deleteEnvironmentStub.calledOnce); + }); + + test('Create venv with pre-existing .venv, user selects re-create, delete env failed', async () => { + pickExistingVenvActionStub.resolves(venvUtils.ExistingVenvAction.Recreate); + pickWorkspaceFolderStub.resolves(workspace1); + deleteEnvironmentStub.resolves(false); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve('/usr/bin/python')) + .verifiable(typemoq.Times.once()); + + pickPackagesToInstallStub.resolves([]); + + await assert.isRejected(venvProvider.createEnvironment()); + + interpreterQuickPick.verifyAll(); + assert.isTrue(withProgressStub.notCalled); + assert.isTrue(showErrorMessageWithLogsStub.notCalled); + assert.isTrue(deleteEnvironmentStub.calledOnce); + }); + + test('Create venv with pre-existing .venv, user selects use existing', async () => { + pickExistingVenvActionStub.resolves(venvUtils.ExistingVenvAction.UseExisting); + pickWorkspaceFolderStub.resolves(workspace1); + + interpreterQuickPick + .setup((i) => i.getInterpreterViaQuickPick(typemoq.It.isAny(), typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve('/usr/bin/python')) + .verifiable(typemoq.Times.never()); + + pickPackagesToInstallStub.resolves([]); + + interpreterQuickPick.verifyAll(); + assert.isTrue(withProgressStub.notCalled); + assert.isTrue(pickPackagesToInstallStub.notCalled); + assert.isTrue(showErrorMessageWithLogsStub.notCalled); + assert.isTrue(deleteEnvironmentStub.notCalled); }); }); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvDeleteUtils.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvDeleteUtils.unit.test.ts new file mode 100644 index 00000000000..d075979b70b --- /dev/null +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvDeleteUtils.unit.test.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import * as fs from 'fs-extra'; +import * as sinon from 'sinon'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { assert } from 'chai'; +import * as path from 'path'; +import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; +import * as commonUtils from '../../../../client/pythonEnvironments/creation/common/commonUtils'; +import { + deleteEnvironmentNonWindows, + deleteEnvironmentWindows, +} from '../../../../client/pythonEnvironments/creation/provider/venvDeleteUtils'; +import * as switchPython from '../../../../client/pythonEnvironments/creation/provider/venvSwitchPython'; +import * as asyncApi from '../../../../client/common/utils/async'; + +suite('Test Delete environments (windows)', () => { + let pathExistsStub: sinon.SinonStub; + let rmdirStub: sinon.SinonStub; + let unlinkStub: sinon.SinonStub; + let showErrorMessageWithLogsStub: sinon.SinonStub; + let switchPythonStub: sinon.SinonStub; + let sleepStub: sinon.SinonStub; + + const workspace1: WorkspaceFolder = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }; + + setup(() => { + pathExistsStub = sinon.stub(fs, 'pathExists'); + pathExistsStub.resolves(true); + + rmdirStub = sinon.stub(fs, 'rmdir'); + unlinkStub = sinon.stub(fs, 'unlink'); + + sleepStub = sinon.stub(asyncApi, 'sleep'); + sleepStub.resolves(); + + showErrorMessageWithLogsStub = sinon.stub(commonUtils, 'showErrorMessageWithLogs'); + showErrorMessageWithLogsStub.resolves(); + + switchPythonStub = sinon.stub(switchPython, 'switchSelectedPython'); + switchPythonStub.resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Delete venv folder succeeded', async () => { + rmdirStub.resolves(); + unlinkStub.resolves(); + assert.ok(await deleteEnvironmentWindows(workspace1, 'python.exe')); + + assert.ok(rmdirStub.calledOnce); + assert.ok(unlinkStub.calledOnce); + assert.ok(showErrorMessageWithLogsStub.notCalled); + }); + + test('Delete python.exe succeeded but venv dir failed', async () => { + rmdirStub.rejects(); + unlinkStub.resolves(); + assert.notOk(await deleteEnvironmentWindows(workspace1, 'python.exe')); + + assert.ok(rmdirStub.calledOnce); + assert.ok(unlinkStub.calledOnce); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); + + test('Delete python.exe failed first attempt', async () => { + unlinkStub.rejects(); + rmdirStub.resolves(); + assert.ok(await deleteEnvironmentWindows(workspace1, 'python.exe')); + + assert.ok(rmdirStub.calledOnce); + assert.ok(switchPythonStub.calledOnce); + assert.ok(showErrorMessageWithLogsStub.notCalled); + }); + + test('Delete python.exe failed all attempts', async () => { + unlinkStub.rejects(); + rmdirStub.rejects(); + assert.notOk(await deleteEnvironmentWindows(workspace1, 'python.exe')); + assert.ok(switchPythonStub.calledOnce); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); + + test('Delete python.exe failed no interpreter', async () => { + unlinkStub.rejects(); + rmdirStub.rejects(); + assert.notOk(await deleteEnvironmentWindows(workspace1, undefined)); + assert.ok(switchPythonStub.notCalled); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); +}); + +suite('Test Delete environments (linux/mac)', () => { + let pathExistsStub: sinon.SinonStub; + let rmdirStub: sinon.SinonStub; + let showErrorMessageWithLogsStub: sinon.SinonStub; + + const workspace1: WorkspaceFolder = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }; + + setup(() => { + pathExistsStub = sinon.stub(fs, 'pathExists'); + rmdirStub = sinon.stub(fs, 'rmdir'); + + showErrorMessageWithLogsStub = sinon.stub(commonUtils, 'showErrorMessageWithLogs'); + showErrorMessageWithLogsStub.resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Delete venv folder succeeded', async () => { + pathExistsStub.resolves(true); + rmdirStub.resolves(); + + assert.ok(await deleteEnvironmentNonWindows(workspace1)); + + assert.ok(pathExistsStub.calledOnce); + assert.ok(rmdirStub.calledOnce); + assert.ok(showErrorMessageWithLogsStub.notCalled); + }); + + test('Delete venv folder failed', async () => { + pathExistsStub.resolves(true); + rmdirStub.rejects(); + assert.notOk(await deleteEnvironmentNonWindows(workspace1)); + + assert.ok(pathExistsStub.calledOnce); + assert.ok(rmdirStub.calledOnce); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); +}); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts index 360bb43fad4..ae4f43a0296 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvUtils.unit.test.ts @@ -8,7 +8,11 @@ import { Uri } from 'vscode'; import * as path from 'path'; import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; import * as workspaceApis from '../../../../client/common/vscodeApis/workspaceApis'; -import { pickPackagesToInstall } from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; +import { + ExistingVenvAction, + pickExistingVenvAction, + pickPackagesToInstall, +} from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../../../constants'; import { CreateEnv } from '../../../../client/common/utils/localize'; @@ -346,3 +350,65 @@ suite('Venv Utils test', () => { assert.isTrue(readFileStub.notCalled); }); }); + +suite('Test pick existing venv action', () => { + let withProgressStub: sinon.SinonStub; + let showQuickPickWithBackStub: sinon.SinonStub; + let pathExistsStub: sinon.SinonStub; + + const workspace1 = { + uri: Uri.file(path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'testMultiRootWkspc', 'workspace1')), + name: 'workspace1', + index: 0, + }; + + setup(() => { + pathExistsStub = sinon.stub(fs, 'pathExists'); + withProgressStub = sinon.stub(windowApis, 'withProgress'); + showQuickPickWithBackStub = sinon.stub(windowApis, 'showQuickPickWithBack'); + }); + teardown(() => { + sinon.restore(); + }); + + test('User selects existing venv', async () => { + pathExistsStub.resolves(true); + showQuickPickWithBackStub.resolves({ + label: CreateEnv.Venv.useExisting, + description: CreateEnv.Venv.useExistingDescription, + }); + const actual = await pickExistingVenvAction(workspace1); + assert.deepStrictEqual(actual, ExistingVenvAction.UseExisting); + }); + + test('User presses escape', async () => { + pathExistsStub.resolves(true); + showQuickPickWithBackStub.resolves(undefined); + await assert.isRejected(pickExistingVenvAction(workspace1)); + }); + + test('User selects delete venv', async () => { + pathExistsStub.resolves(true); + showQuickPickWithBackStub.resolves({ + label: CreateEnv.Venv.recreate, + description: CreateEnv.Venv.recreateDescription, + }); + withProgressStub.resolves(true); + const actual = await pickExistingVenvAction(workspace1); + assert.deepStrictEqual(actual, ExistingVenvAction.Recreate); + }); + + test('User clicks on back', async () => { + pathExistsStub.resolves(true); + // We use reject with "Back" to simulate the user clicking on back. + showQuickPickWithBackStub.rejects(windowApis.MultiStepAction.Back); + withProgressStub.resolves(false); + await assert.isRejected(pickExistingVenvAction(workspace1)); + }); + + test('No venv found', async () => { + pathExistsStub.resolves(false); + const actual = await pickExistingVenvAction(workspace1); + assert.deepStrictEqual(actual, ExistingVenvAction.Create); + }); +}); diff --git a/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts b/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts index 3676834873a..30f95c94d21 100644 --- a/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts +++ b/extensions/positron-python/src/test/terminals/codeExecution/codeExecutionManager.unit.test.ts @@ -77,12 +77,15 @@ suite('Terminal - Code Execution Manager', () => { executionManager.registerCommands(); const sorted = registered.sort(); - expect(sorted).to.deep.equal([ - Commands.Exec_In_Terminal, - Commands.Exec_In_Terminal_Icon, - Commands.Exec_Selection_In_Django_Shell, - Commands.Exec_Selection_In_Terminal, - ]); + expect(sorted).to.deep.equal( + [ + Commands.Exec_In_Separate_Terminal, + Commands.Exec_In_Terminal, + Commands.Exec_In_Terminal_Icon, + Commands.Exec_Selection_In_Django_Shell, + Commands.Exec_Selection_In_Terminal, + ].sort(), + ); }); test('Ensure executeFileInterTerminal will do nothing if no file is avialble', async () => { @@ -135,7 +138,10 @@ suite('Terminal - Code Execution Manager', () => { const fileToExecute = Uri.file('x'); await commandHandler!(fileToExecute); helper.verify(async (h) => h.getFileToExecute(), TypeMoq.Times.never()); - executionService.verify(async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute)), TypeMoq.Times.once()); + executionService.verify( + async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute), TypeMoq.It.isAny()), + TypeMoq.Times.once(), + ); }); test('Ensure executeFileInterTerminal will use active file', async () => { @@ -164,7 +170,10 @@ suite('Terminal - Code Execution Manager', () => { .returns(() => executionService.object); await commandHandler!(fileToExecute); - executionService.verify(async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute)), TypeMoq.Times.once()); + executionService.verify( + async (e) => e.executeFile(TypeMoq.It.isValue(fileToExecute), TypeMoq.It.isAny()), + TypeMoq.Times.once(), + ); }); async function testExecutionOfSelectionWithoutAnyActiveDocument(commandId: string, executionSericeId: string) { diff --git a/extensions/positron-python/src/test/terminals/codeExecution/terminalCodeExec.unit.test.ts b/extensions/positron-python/src/test/terminals/codeExecution/terminalCodeExec.unit.test.ts index 1f33b619fad..9523f35a104 100644 --- a/extensions/positron-python/src/test/terminals/codeExecution/terminalCodeExec.unit.test.ts +++ b/extensions/positron-python/src/test/terminals/codeExecution/terminalCodeExec.unit.test.ts @@ -25,7 +25,7 @@ import { TerminalCodeExecutionProvider } from '../../../client/terminals/codeExe import { ICodeExecutionService } from '../../../client/terminals/types'; import { PYTHON_PATH } from '../../common'; import * as sinon from 'sinon'; -import assert from 'assert'; +import { assert } from 'chai'; import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; import { IInterpreterService } from '../../../client/interpreter/contracts'; @@ -390,6 +390,7 @@ suite('Terminal - Code Execution', () => { const env = await createCondaEnv(condaEnv, procService.object, fileSystem.object); if (!env) { assert(false, 'Should not be undefined for conda version 4.9.0'); + return; } const procs = createPythonProcessService(procService.object, env); const condaExecutionService = { @@ -509,6 +510,7 @@ suite('Terminal - Code Execution', () => { const env = await createCondaEnv(condaEnv, procService.object, fileSystem.object); if (!env) { assert(false, 'Should not be undefined for conda version 4.9.0'); + return; } const procs = createPythonProcessService(procService.object, env); const condaExecutionService = { @@ -650,6 +652,31 @@ suite('Terminal - Code Execution', () => { await executor.execute('cmd2'); terminalService.verify(async (t) => t.sendText('cmd2'), TypeMoq.Times.once()); }); + + test('Ensure code is sent to the same terminal for a particular resource', async () => { + const resource = Uri.file('a'); + terminalFactory.reset(); + terminalFactory + .setup((f) => f.getTerminalService(TypeMoq.It.isAny())) + .callback((options: TerminalCreationOptions) => { + assert.deepEqual(options.resource, resource); + }) + .returns(() => terminalService.object); + + const pythonPath = 'usr/bin/python1234'; + const terminalArgs = ['-a', 'b', 'c']; + platform.setup((p) => p.isWindows).returns(() => false); + interpreterService + .setup((s) => s.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); + terminalSettings.setup((t) => t.launchArgs).returns(() => terminalArgs); + + await executor.execute('cmd1', resource); + terminalService.verify(async (t) => t.sendText('cmd1'), TypeMoq.Times.once()); + + await executor.execute('cmd2', resource); + terminalService.verify(async (t) => t.sendText('cmd2'), TypeMoq.Times.once()); + }); }); }); }); diff --git a/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts b/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts index 4712c9b6136..bbb65f0b2e2 100644 --- a/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts +++ b/extensions/positron-python/src/test/testing/common/debugLauncher.unit.test.ts @@ -215,6 +215,9 @@ suite('Unit Tests - Debug Launcher', () => { if (!expected.cwd) { expected.cwd = workspaceFolders[0].uri.fsPath; } + const pluginPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles'); + const pythonPath = `${pluginPath}${path.delimiter}${expected.cwd}`; + expected.env.PYTHONPATH = pythonPath; // added by LaunchConfigurationResolver: if (!expected.python) { @@ -342,6 +345,10 @@ suite('Unit Tests - Debug Launcher', () => { }; const expected = getDefaultDebugConfig(); expected.cwd = 'path/to/settings/cwd'; + const pluginPath = path.join(EXTENSION_ROOT_DIR, 'pythonFiles'); + const pythonPath = `${pluginPath}${path.delimiter}${expected.cwd}`; + expected.env.PYTHONPATH = pythonPath; + setupSuccess(options, 'unittest', expected); await debugLauncher.launchDebugger(options); @@ -366,6 +373,7 @@ suite('Unit Tests - Debug Launcher', () => { console: 'integratedTerminal', cwd: 'some/dir', env: { + PYTHONPATH: 'one/two/three', SPAM: 'EGGS', }, envFile: 'some/dir/.env', diff --git a/extensions/positron-python/src/test/testing/common/helpers.unit.test.ts b/extensions/positron-python/src/test/testing/common/helpers.unit.test.ts new file mode 100644 index 00000000000..441b257d4d0 --- /dev/null +++ b/extensions/positron-python/src/test/testing/common/helpers.unit.test.ts @@ -0,0 +1,48 @@ +import * as path from 'path'; +import * as assert from 'assert'; +import { addPathToPythonpath } from '../../../client/testing/common/helpers'; + +suite('Unit Tests - Test Helpers', () => { + const newPaths = [path.join('path', 'to', 'new')]; + test('addPathToPythonpath handles undefined path', async () => { + const launchPythonPath = undefined; + const actualPath = addPathToPythonpath(newPaths, launchPythonPath); + assert.equal(actualPath, path.join('path', 'to', 'new')); + }); + test('addPathToPythonpath adds path if it does not exist in the python path', async () => { + const launchPythonPath = path.join('random', 'existing', 'pythonpath'); + const actualPath = addPathToPythonpath(newPaths, launchPythonPath); + const expectedPath = + path.join('random', 'existing', 'pythonpath') + path.delimiter + path.join('path', 'to', 'new'); + assert.equal(actualPath, expectedPath); + }); + test('addPathToPythonpath does not add to python path if the given python path already contains the path', async () => { + const launchPythonPath = path.join('path', 'to', 'new'); + const actualPath = addPathToPythonpath(newPaths, launchPythonPath); + const expectedPath = path.join('path', 'to', 'new'); + assert.equal(actualPath, expectedPath); + }); + test('addPathToPythonpath correctly normalizes both existing and new paths', async () => { + const newerPaths = [path.join('path', 'to', '/', 'new')]; + const launchPythonPath = path.join('path', 'to', '..', 'old'); + const actualPath = addPathToPythonpath(newerPaths, launchPythonPath); + const expectedPath = path.join('path', 'old') + path.delimiter + path.join('path', 'to', 'new'); + assert.equal(actualPath, expectedPath); + }); + test('addPathToPythonpath splits pythonpath then rejoins it', async () => { + const launchPythonPath = + path.join('path', 'to', 'new') + + path.delimiter + + path.join('path', 'to', 'old') + + path.delimiter + + path.join('path', 'to', 'random'); + const actualPath = addPathToPythonpath(newPaths, launchPythonPath); + const expectedPath = + path.join('path', 'to', 'new') + + path.delimiter + + path.join('path', 'to', 'old') + + path.delimiter + + path.join('path', 'to', 'random'); + assert.equal(actualPath, expectedPath); + }); +}); diff --git a/extensions/positron-python/src/test/testing/common/testingAdapter.test.ts b/extensions/positron-python/src/test/testing/common/testingAdapter.test.ts new file mode 100644 index 00000000000..1334085e4ce --- /dev/null +++ b/extensions/positron-python/src/test/testing/common/testingAdapter.test.ts @@ -0,0 +1,427 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import { TestRun, Uri } from 'vscode'; +import * as typeMoq from 'typemoq'; +import * as path from 'path'; +import * as assert from 'assert'; +import { PytestTestDiscoveryAdapter } from '../../../client/testing/testController/pytest/pytestDiscoveryAdapter'; +import { ITestResultResolver, ITestServer } from '../../../client/testing/testController/common/types'; +import { PythonTestServer } from '../../../client/testing/testController/common/server'; +import { IPythonExecutionFactory } from '../../../client/common/process/types'; +import { ITestDebugLauncher } from '../../../client/testing/common/types'; +import { IConfigurationService, ITestOutputChannel } from '../../../client/common/types'; +import { IServiceContainer } from '../../../client/ioc/types'; +import { EXTENSION_ROOT_DIR_FOR_TESTS, initialize } from '../../initialize'; +import { traceError, traceLog } from '../../../client/logging'; +import { PytestTestExecutionAdapter } from '../../../client/testing/testController/pytest/pytestExecutionAdapter'; +import { UnittestTestDiscoveryAdapter } from '../../../client/testing/testController/unittest/testDiscoveryAdapter'; +import { UnittestTestExecutionAdapter } from '../../../client/testing/testController/unittest/testExecutionAdapter'; + +suite('End to End Tests: test adapters', () => { + let resultResolver: typeMoq.IMock; + let pythonTestServer: ITestServer; + let pythonExecFactory: IPythonExecutionFactory; + let debugLauncher: ITestDebugLauncher; + let configService: IConfigurationService; + let testOutputChannel: ITestOutputChannel; + let serviceContainer: IServiceContainer; + let workspaceUri: Uri; + const rootPathSmallWorkspace = path.join( + EXTENSION_ROOT_DIR_FOR_TESTS, + 'src', + 'testTestingRootWkspc', + 'smallWorkspace', + ); + const rootPathLargeWorkspace = path.join( + EXTENSION_ROOT_DIR_FOR_TESTS, + 'src', + 'testTestingRootWkspc', + 'largeWorkspace', + ); + suiteSetup(async () => { + serviceContainer = (await initialize()).serviceContainer; + }); + + setup(async () => { + // create objects that were injected + configService = serviceContainer.get(IConfigurationService); + pythonExecFactory = serviceContainer.get(IPythonExecutionFactory); + debugLauncher = serviceContainer.get(ITestDebugLauncher); + testOutputChannel = serviceContainer.get(ITestOutputChannel); + + // create mock resultResolver object + resultResolver = typeMoq.Mock.ofType(); + + // create objects that were not injected + pythonTestServer = new PythonTestServer(pythonExecFactory, debugLauncher); + await pythonTestServer.serverReady(); + }); + test('unittest discovery adapter small workspace', async () => { + // result resolver and saved data for assertions + let actualData: { + status: unknown; + error: string | any[]; + tests: unknown; + }; + resultResolver + .setup((x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveDiscovery ${data}`); + actualData = data; + return Promise.resolve(); + }); + + // set workspace to test workspace folder and set up settings + workspaceUri = Uri.parse(rootPathSmallWorkspace); + configService.getSettings(workspaceUri).testing.unittestArgs = ['-s', '.', '-p', '*test*.py']; + + // run unittest discovery + const discoveryAdapter = new UnittestTestDiscoveryAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + + await discoveryAdapter.discoverTests(workspaceUri).finally(() => { + // verification after discovery is complete + resultResolver.verify( + (x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.once(), + ); + + // 1. Check the status is "success" + assert.strictEqual(actualData.status, 'success', "Expected status to be 'success'"); + // 2. Confirm no errors + assert.strictEqual(actualData.error, undefined, "Expected no errors in 'error' field"); + // 3. Confirm tests are found + assert.ok(actualData.tests, 'Expected tests to be present'); + }); + }); + + test('unittest discovery adapter large workspace', async () => { + // result resolver and saved data for assertions + let actualData: { + status: unknown; + error: string | any[]; + tests: unknown; + }; + resultResolver + .setup((x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveDiscovery ${data}`); + actualData = data; + return Promise.resolve(); + }); + + // set settings to work for the given workspace + workspaceUri = Uri.parse(rootPathLargeWorkspace); + configService.getSettings(workspaceUri).testing.unittestArgs = ['-s', '.', '-p', '*test*.py']; + // run discovery + const discoveryAdapter = new UnittestTestDiscoveryAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + + await discoveryAdapter.discoverTests(workspaceUri).finally(() => { + // verification after discovery is complete + resultResolver.verify( + (x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.once(), + ); + + // 1. Check the status is "success" + assert.strictEqual(actualData.status, 'success', "Expected status to be 'success'"); + // 2. Confirm no errors + assert.strictEqual(actualData.error, undefined, "Expected no errors in 'error' field"); + // 3. Confirm tests are found + assert.ok(actualData.tests, 'Expected tests to be present'); + }); + }); + test('pytest discovery adapter small workspace', async () => { + // result resolver and saved data for assertions + let actualData: { + status: unknown; + error: string | any[]; + tests: unknown; + }; + resultResolver + .setup((x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveDiscovery ${data}`); + actualData = data; + return Promise.resolve(); + }); + // run pytest discovery + const discoveryAdapter = new PytestTestDiscoveryAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathSmallWorkspace); + + await discoveryAdapter.discoverTests(workspaceUri, pythonExecFactory).finally(() => { + // verification after discovery is complete + resultResolver.verify( + (x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.once(), + ); + + // 1. Check the status is "success" + assert.strictEqual(actualData.status, 'success', "Expected status to be 'success'"); + // 2. Confirm no errors + assert.strictEqual(actualData.error.length, 0, "Expected no errors in 'error' field"); + // 3. Confirm tests are found + assert.ok(actualData.tests, 'Expected tests to be present'); + }); + }); + test('pytest discovery adapter large workspace', async () => { + // result resolver and saved data for assertions + let actualData: { + status: unknown; + error: string | any[]; + tests: unknown; + }; + resultResolver + .setup((x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveDiscovery ${data}`); + actualData = data; + return Promise.resolve(); + }); + // run pytest discovery + const discoveryAdapter = new PytestTestDiscoveryAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathLargeWorkspace); + + await discoveryAdapter.discoverTests(workspaceUri, pythonExecFactory).finally(() => { + // verification after discovery is complete + resultResolver.verify( + (x) => x.resolveDiscovery(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.once(), + ); + + // 1. Check the status is "success" + assert.strictEqual(actualData.status, 'success', "Expected status to be 'success'"); + // 2. Confirm no errors + assert.strictEqual(actualData.error.length, 0, "Expected no errors in 'error' field"); + // 3. Confirm tests are found + assert.ok(actualData.tests, 'Expected tests to be present'); + }); + }); + test('unittest execution adapter small workspace', async () => { + // result resolver and saved data for assertions + let actualData: { + status: unknown; + error: string | any[]; + result: unknown; + }; + resultResolver + .setup((x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveExecution ${data}`); + actualData = data; + return Promise.resolve(); + }); + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathSmallWorkspace); + configService.getSettings(workspaceUri).testing.unittestArgs = ['-s', '.', '-p', '*test*.py']; + // run execution + const executionAdapter = new UnittestTestExecutionAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + await executionAdapter + .runTests(workspaceUri, ['test_simple.SimpleClass.test_simple_unit'], false, testRun.object) + .finally(() => { + // verification after execution is complete + resultResolver.verify( + (x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.once(), + ); + + // 1. Check the status is "success" + assert.strictEqual(actualData.status, 'success', "Expected status to be 'success'"); + // 2. Confirm tests are found + assert.ok(actualData.result, 'Expected results to be present'); + }); + }); + test('unittest execution adapter large workspace', async () => { + // result resolver and saved data for assertions + resultResolver + .setup((x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceError(`resolveExecution ${data}`); + traceLog(`resolveExecution ${data}`); + // do the following asserts for each time resolveExecution is called, should be called once per test. + // 1. Check the status, can be subtest success or failure + assert( + data.status === 'subtest-success' || data.status === 'subtest-failure', + "Expected status to be 'subtest-success' or 'subtest-failure'", + ); + // 2. Confirm tests are found + assert.ok(data.result, 'Expected results to be present'); + return Promise.resolve(); + }); + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathLargeWorkspace); + configService.getSettings(workspaceUri).testing.unittestArgs = ['-s', '.', '-p', '*test*.py']; + + // run unittest execution + const executionAdapter = new UnittestTestExecutionAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + await executionAdapter + .runTests(workspaceUri, ['test_parameterized_subtest.NumbersTest.test_even'], false, testRun.object) + .finally(() => { + // verification after discovery is complete + resultResolver.verify( + (x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.atLeastOnce(), + ); + }); + }); + test('pytest execution adapter small workspace', async () => { + // result resolver and saved data for assertions + let actualData: { + status: unknown; + error: string | any[]; + result: unknown; + }; + resultResolver + .setup((x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveExecution ${data}`); + actualData = data; + return Promise.resolve(); + }); + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathSmallWorkspace); + + // run pytest execution + const executionAdapter = new PytestTestExecutionAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + await executionAdapter + .runTests( + workspaceUri, + [`${rootPathSmallWorkspace}/test_simple.py::test_a`], + false, + testRun.object, + pythonExecFactory, + ) + .finally(() => { + // verification after discovery is complete + resultResolver.verify( + (x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.once(), + ); + + // 1. Check the status is "success" + assert.strictEqual(actualData.status, 'success', "Expected status to be 'success'"); + // 2. Confirm no errors + assert.strictEqual(actualData.error, null, "Expected no errors in 'error' field"); + // 3. Confirm tests are found + assert.ok(actualData.result, 'Expected results to be present'); + }); + }); + test('pytest execution adapter large workspace', async () => { + resultResolver + .setup((x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns((data) => { + traceLog(`resolveExecution ${data}`); + // do the following asserts for each time resolveExecution is called, should be called once per test. + // 1. Check the status is "success" + assert.strictEqual(data.status, 'success', "Expected status to be 'success'"); + // 2. Confirm no errors + assert.strictEqual(data.error, null, "Expected no errors in 'error' field"); + // 3. Confirm tests are found + assert.ok(data.result, 'Expected results to be present'); + return Promise.resolve(); + }); + + // set workspace to test workspace folder + workspaceUri = Uri.parse(rootPathLargeWorkspace); + + // generate list of test_ids + const testIds: string[] = []; + for (let i = 0; i < 200; i = i + 1) { + const testId = `${rootPathLargeWorkspace}/test_parameterized_subtest.py::test_odd_even[${i}]`; + testIds.push(testId); + } + + // run pytest execution + const executionAdapter = new PytestTestExecutionAdapter( + pythonTestServer, + configService, + testOutputChannel, + resultResolver.object, + ); + const testRun = typeMoq.Mock.ofType(); + testRun + .setup((t) => t.token) + .returns( + () => + ({ + onCancellationRequested: () => undefined, + } as any), + ); + await executionAdapter.runTests(workspaceUri, testIds, false, testRun.object, pythonExecFactory).finally(() => { + // resolve execution should be called 200 times since there are 200 tests run. + resultResolver.verify( + (x) => x.resolveExecution(typeMoq.It.isAny(), typeMoq.It.isAny()), + typeMoq.Times.exactly(200), + ); + }); + }); +}); diff --git a/extensions/positron-python/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts b/extensions/positron-python/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts index 18212b2d103..8ba7dd9a6f0 100644 --- a/extensions/positron-python/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts +++ b/extensions/positron-python/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts @@ -5,6 +5,7 @@ import * as assert from 'assert'; import { Uri } from 'vscode'; import * as typeMoq from 'typemoq'; import * as path from 'path'; +import { Observable } from 'rxjs/Observable'; import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; import { PytestTestDiscoveryAdapter } from '../../../../client/testing/testController/pytest/pytestDiscoveryAdapter'; import { ITestServer } from '../../../../client/testing/testController/common/types'; @@ -12,9 +13,11 @@ import { IPythonExecutionFactory, IPythonExecutionService, SpawnOptions, + Output, } from '../../../../client/common/process/types'; -import { createDeferred, Deferred } from '../../../../client/common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../../client/constants'; +import { MockChildProcess } from '../../../mocks/mockChildProcess'; +import { Deferred, createDeferred } from '../../../../client/common/utils/async'; suite('pytest test discovery adapter', () => { let testServer: typeMoq.IMock; @@ -29,6 +32,7 @@ suite('pytest test discovery adapter', () => { let expectedPath: string; let uri: Uri; let expectedExtraVariables: Record; + let mockProc: MockChildProcess; setup(() => { const mockExtensionRootDir = typeMoq.Mock.ofType(); @@ -66,32 +70,46 @@ suite('pytest test discovery adapter', () => { }), } as unknown) as IConfigurationService; - // set up exec factory - execFactory = typeMoq.Mock.ofType(); - execFactory - .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) - .returns(() => Promise.resolve(execService.object)); - - // set up exec service + // set up exec service with child process + mockProc = new MockChildProcess('', ['']); execService = typeMoq.Mock.ofType(); - deferred = createDeferred(); + const output = new Observable>(() => { + /* no op */ + }); execService - .setup((x) => x.exec(typeMoq.It.isAny(), typeMoq.It.isAny())) - .returns(() => { - deferred.resolve(); - return Promise.resolve({ stdout: '{}' }); - }); + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => ({ + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + })); execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); outputChannel = typeMoq.Mock.ofType(); }); test('Discovery should call exec with correct basic args', async () => { + // set up exec mock + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService.object); + }); + adapter = new PytestTestDiscoveryAdapter(testServer.object, configService, outputChannel.object); - await adapter.discoverTests(uri, execFactory.object); - const expectedArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only', '.']; + adapter.discoverTests(uri, execFactory.object); + // add in await and trigger + await deferred.promise; + mockProc.trigger('close'); + // verification + const expectedArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only', '.']; execService.verify( (x) => - x.exec( + x.execObservable( expectedArgs, typeMoq.It.is((options) => { assert.deepEqual(options.extraVariables, expectedExtraVariables); @@ -108,16 +126,34 @@ suite('pytest test discovery adapter', () => { const expectedPathNew = path.join('other', 'path'); const configServiceNew: IConfigurationService = ({ getSettings: () => ({ - testing: { pytestArgs: ['.', 'abc', 'xyz'], cwd: expectedPathNew }, + testing: { + pytestArgs: ['.', 'abc', 'xyz'], + cwd: expectedPathNew, + }, }), } as unknown) as IConfigurationService; + // set up exec mock + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService.object); + }); + adapter = new PytestTestDiscoveryAdapter(testServer.object, configServiceNew, outputChannel.object); - await adapter.discoverTests(uri, execFactory.object); + adapter.discoverTests(uri, execFactory.object); + // add in await and trigger + await deferred.promise; + mockProc.trigger('close'); + + // verification const expectedArgs = ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only', '.', 'abc', 'xyz']; execService.verify( (x) => - x.exec( + x.execObservable( expectedArgs, typeMoq.It.is((options) => { assert.deepEqual(options.extraVariables, expectedExtraVariables); diff --git a/extensions/positron-python/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts b/extensions/positron-python/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts index 44116fd753b..43b763f56e6 100644 --- a/extensions/positron-python/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts +++ b/extensions/positron-python/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts @@ -6,11 +6,13 @@ import { TestRun, Uri } from 'vscode'; import * as typeMoq from 'typemoq'; import * as sinon from 'sinon'; import * as path from 'path'; +import { Observable } from 'rxjs/Observable'; import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; import { ITestServer } from '../../../../client/testing/testController/common/types'; import { IPythonExecutionFactory, IPythonExecutionService, + Output, SpawnOptions, } from '../../../../client/common/process/types'; import { createDeferred, Deferred } from '../../../../client/common/utils/async'; @@ -18,6 +20,7 @@ import { PytestTestExecutionAdapter } from '../../../../client/testing/testContr import { ITestDebugLauncher, LaunchOptions } from '../../../../client/testing/common/types'; import * as util from '../../../../client/testing/testController/common/utils'; import { EXTENSION_ROOT_DIR } from '../../../../client/constants'; +import { MockChildProcess } from '../../../mocks/mockChildProcess'; suite('pytest test execution adapter', () => { let testServer: typeMoq.IMock; @@ -29,8 +32,8 @@ suite('pytest test execution adapter', () => { let debugLauncher: typeMoq.IMock; (global as any).EXTENSION_ROOT_DIR = EXTENSION_ROOT_DIR; let myTestPath: string; - let startTestIdServerStub: sinon.SinonStub; - + let mockProc: MockChildProcess; + let utilsStub: sinon.SinonStub; setup(() => { testServer = typeMoq.Mock.ofType(); testServer.setup((t) => t.getPort()).returns(() => 12345); @@ -47,8 +50,24 @@ suite('pytest test execution adapter', () => { }), isTestExecution: () => false, } as unknown) as IConfigurationService; - execFactory = typeMoq.Mock.ofType(); + + // set up exec service with child process + mockProc = new MockChildProcess('', ['']); + const output = new Observable>(() => { + /* no op */ + }); execService = typeMoq.Mock.ofType(); + execService + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => ({ + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + })); + execFactory = typeMoq.Mock.ofType(); + utilsStub = sinon.stub(util, 'startTestIdServer'); debugLauncher = typeMoq.Mock.ofType(); execFactory .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) @@ -66,7 +85,6 @@ suite('pytest test execution adapter', () => { deferred.resolve(); return Promise.resolve(); }); - startTestIdServerStub = sinon.stub(util, 'startTestIdServer').returns(Promise.resolve(54321)); execFactory.setup((p) => ((p as unknown) as any).then).returns(() => undefined); execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); @@ -77,10 +95,25 @@ suite('pytest test execution adapter', () => { sinon.restore(); }); test('startTestIdServer called with correct testIds', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve(54321); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); const uri = Uri.file(myTestPath); const uuid = 'uuid123'; testServer - .setup((t) => t.onDiscoveryDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((t) => t.onRunDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => ({ dispose: () => { /* no-body */ @@ -88,19 +121,38 @@ suite('pytest test execution adapter', () => { })); testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); const outputChannel = typeMoq.Mock.ofType(); - const testRun = typeMoq.Mock.ofType(); adapter = new PytestTestExecutionAdapter(testServer.object, configService, outputChannel.object); - const testIds = ['test1id', 'test2id']; - await adapter.runTests(uri, testIds, false, testRun.object, execFactory.object); + adapter.runTests(uri, testIds, false, testRun.object, execFactory.object); - sinon.assert.calledWithExactly(startTestIdServerStub, testIds); + // add in await and trigger + await deferred2.promise; + await deferred3.promise; + mockProc.trigger('close'); + + // assert + sinon.assert.calledWithExactly(utilsStub, testIds); }); test('pytest execution called with correct args', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve(54321); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); const uri = Uri.file(myTestPath); const uuid = 'uuid123'; testServer - .setup((t) => t.onDiscoveryDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((t) => t.onRunDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => ({ dispose: () => { /* no-body */ @@ -108,9 +160,12 @@ suite('pytest test execution adapter', () => { })); testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); const outputChannel = typeMoq.Mock.ofType(); - const testRun = typeMoq.Mock.ofType(); adapter = new PytestTestExecutionAdapter(testServer.object, configService, outputChannel.object); - await adapter.runTests(uri, [], false, testRun.object, execFactory.object); + adapter.runTests(uri, [], false, testRun.object, execFactory.object); + + await deferred2.promise; + await deferred3.promise; + mockProc.trigger('close'); const pathToPythonFiles = path.join(EXTENSION_ROOT_DIR, 'pythonFiles'); const pathToPythonScript = path.join(pathToPythonFiles, 'vscode_pytest', 'run_pytest_script.py'); @@ -123,7 +178,7 @@ suite('pytest test execution adapter', () => { // execService.verify((x) => x.exec(expectedArgs, typeMoq.It.isAny()), typeMoq.Times.once()); execService.verify( (x) => - x.exec( + x.execObservable( expectedArgs, typeMoq.It.is((options) => { assert.equal(options.extraVariables?.PYTHONPATH, expectedExtraVariables.PYTHONPATH); @@ -139,6 +194,21 @@ suite('pytest test execution adapter', () => { ); }); test('pytest execution respects settings.testing.cwd when present', async () => { + const deferred2 = createDeferred(); + const deferred3 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); + utilsStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve(54321); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); const newCwd = path.join('new', 'path'); configService = ({ getSettings: () => ({ @@ -149,7 +219,7 @@ suite('pytest test execution adapter', () => { const uri = Uri.file(myTestPath); const uuid = 'uuid123'; testServer - .setup((t) => t.onDiscoveryDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((t) => t.onRunDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => ({ dispose: () => { /* no-body */ @@ -157,9 +227,12 @@ suite('pytest test execution adapter', () => { })); testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); const outputChannel = typeMoq.Mock.ofType(); - const testRun = typeMoq.Mock.ofType(); adapter = new PytestTestExecutionAdapter(testServer.object, configService, outputChannel.object); - await adapter.runTests(uri, [], false, testRun.object, execFactory.object); + adapter.runTests(uri, [], false, testRun.object, execFactory.object); + + await deferred2.promise; + await deferred3.promise; + mockProc.trigger('close'); const pathToPythonFiles = path.join(EXTENSION_ROOT_DIR, 'pythonFiles'); const pathToPythonScript = path.join(pathToPythonFiles, 'vscode_pytest', 'run_pytest_script.py'); @@ -172,7 +245,7 @@ suite('pytest test execution adapter', () => { execService.verify( (x) => - x.exec( + x.execObservable( expectedArgs, typeMoq.It.is((options) => { assert.equal(options.extraVariables?.PYTHONPATH, expectedExtraVariables.PYTHONPATH); @@ -188,10 +261,17 @@ suite('pytest test execution adapter', () => { ); }); test('Debug launched correctly for pytest', async () => { + const deferred3 = createDeferred(); + utilsStub.callsFake(() => { + deferred3.resolve(); + return Promise.resolve(54321); + }); + const testRun = typeMoq.Mock.ofType(); + testRun.setup((t) => t.token).returns(() => ({ onCancellationRequested: () => undefined } as any)); const uri = Uri.file(myTestPath); const uuid = 'uuid123'; testServer - .setup((t) => t.onDiscoveryDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) + .setup((t) => t.onRunDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) .returns(() => ({ dispose: () => { /* no-body */ @@ -199,9 +279,9 @@ suite('pytest test execution adapter', () => { })); testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); const outputChannel = typeMoq.Mock.ofType(); - const testRun = typeMoq.Mock.ofType(); adapter = new PytestTestExecutionAdapter(testServer.object, configService, outputChannel.object); await adapter.runTests(uri, [], true, testRun.object, execFactory.object, debugLauncher.object); + await deferred3.promise; debugLauncher.verify( (x) => x.launchDebugger( diff --git a/extensions/positron-python/src/test/testing/testController/server.unit.test.ts b/extensions/positron-python/src/test/testing/testController/server.unit.test.ts index 1131c26c644..53c2b72e40f 100644 --- a/extensions/positron-python/src/test/testing/testController/server.unit.test.ts +++ b/extensions/positron-python/src/test/testing/testController/server.unit.test.ts @@ -1,15 +1,24 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; import * as net from 'net'; import * as sinon from 'sinon'; import * as crypto from 'crypto'; import { OutputChannel, Uri } from 'vscode'; -import { IPythonExecutionFactory, IPythonExecutionService, SpawnOptions } from '../../../client/common/process/types'; +import { Observable } from 'rxjs'; +import * as typeMoq from 'typemoq'; +import { + IPythonExecutionFactory, + IPythonExecutionService, + Output, + SpawnOptions, +} from '../../../client/common/process/types'; import { PythonTestServer } from '../../../client/testing/testController/common/server'; import { ITestDebugLauncher } from '../../../client/testing/common/types'; -import { createDeferred } from '../../../client/common/utils/async'; +import { Deferred, createDeferred } from '../../../client/common/utils/async'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; suite('Python Test Server', () => { const fakeUuid = 'fake-uuid'; @@ -18,10 +27,12 @@ suite('Python Test Server', () => { let stubExecutionService: IPythonExecutionService; let server: PythonTestServer; let sandbox: sinon.SinonSandbox; - let execArgs: string[]; - let spawnOptions: SpawnOptions; let v4Stub: sinon.SinonStub; let debugLauncher: ITestDebugLauncher; + let mockProc: MockChildProcess; + let execService: typeMoq.IMock; + let deferred: Deferred; + let execFactory = typeMoq.Mock.ofType(); setup(() => { sandbox = sinon.createSandbox(); @@ -29,27 +40,42 @@ suite('Python Test Server', () => { v4Stub.returns(fakeUuid); stubExecutionService = ({ - exec: (args: string[], spawnOptionsProvided: SpawnOptions) => { - execArgs = args; - spawnOptions = spawnOptionsProvided; - return Promise.resolve({ stdout: '', stderr: '' }); - }, + execObservable: () => Promise.resolve({ stdout: '', stderr: '' }), } as unknown) as IPythonExecutionService; stubExecutionFactory = ({ createActivatedEnvironment: () => Promise.resolve(stubExecutionService), } as unknown) as IPythonExecutionFactory; + + // set up exec service with child process + mockProc = new MockChildProcess('', ['']); + execService = typeMoq.Mock.ofType(); + const output = new Observable>(() => { + /* no op */ + }); + execService + .setup((x) => x.execObservable(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => ({ + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + })); + execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); }); teardown(() => { sandbox.restore(); - execArgs = []; server.dispose(); }); test('sendCommand should add the port to the command being sent and add the correct extra spawn variables', async () => { const options = { - command: { script: 'myscript', args: ['-foo', 'foo'] }, + command: { + script: 'myscript', + args: ['-foo', 'foo'], + }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', uuid: fakeUuid, @@ -59,17 +85,31 @@ suite('Python Test Server', () => { outputChannel: undefined, token: undefined, throwOnStdErr: true, - extraVariables: { PYTHONPATH: '/foo/bar', RUN_TEST_IDS_PORT: '56789' }, + extraVariables: { + PYTHONPATH: '/foo/bar', + RUN_TEST_IDS_PORT: '56789', + }, } as SpawnOptions; + const deferred2 = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred2.resolve(); + return Promise.resolve(execService.object); + }); - server = new PythonTestServer(stubExecutionFactory, debugLauncher); + server = new PythonTestServer(execFactory.object, debugLauncher); await server.serverReady(); - await server.sendCommand(options, '56789'); - const port = server.getPort(); + server.sendCommand(options, '56789'); + // add in await and trigger + await deferred2.promise; + mockProc.trigger('close'); - assert.deepStrictEqual(execArgs, ['myscript', '--port', `${port}`, '--uuid', fakeUuid, '-foo', 'foo']); - assert.deepStrictEqual(spawnOptions, expectedSpawnOptions); + const port = server.getPort(); + const expectedArgs = ['myscript', '--port', `${port}`, '--uuid', fakeUuid, '-foo', 'foo']; + execService.verify((x) => x.execObservable(expectedArgs, expectedSpawnOptions), typeMoq.Times.once()); }); test('sendCommand should write to an output channel if it is provided as an option', async () => { @@ -80,17 +120,31 @@ suite('Python Test Server', () => { }, } as OutputChannel; const options = { - command: { script: 'myscript', args: ['-foo', 'foo'] }, + command: { + script: 'myscript', + args: ['-foo', 'foo'], + }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', uuid: fakeUuid, outChannel, }; + deferred = createDeferred(); + execFactory = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve(execService.object); + }); - server = new PythonTestServer(stubExecutionFactory, debugLauncher); + server = new PythonTestServer(execFactory.object, debugLauncher); await server.serverReady(); - await server.sendCommand(options); + server.sendCommand(options); + // add in await and trigger + await deferred.promise; + mockProc.trigger('close'); const port = server.getPort(); const expected = ['python', 'myscript', '--port', `${port}`, '--uuid', fakeUuid, '-foo', 'foo'].join(' '); @@ -99,13 +153,12 @@ suite('Python Test Server', () => { }); test('If script execution fails during sendCommand, an onDataReceived event should be fired with the "error" status', async () => { - let eventData: { status: string; errors: string[] }; + let eventData: { status: string; errors: string[] } | undefined; stubExecutionService = ({ - exec: () => { + execObservable: () => { throw new Error('Failed to execute'); }, } as unknown) as IPythonExecutionService; - const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), @@ -122,30 +175,43 @@ suite('Python Test Server', () => { await server.sendCommand(options); - assert.deepStrictEqual(eventData!.status, 'error'); - assert.deepStrictEqual(eventData!.errors, ['Failed to execute']); + assert.notEqual(eventData, undefined); + assert.deepStrictEqual(eventData?.status, 'error'); + assert.deepStrictEqual(eventData?.errors, ['Failed to execute']); }); test('If the server receives malformed data, it should display a log message, and not fire an event', async () => { let eventData: string | undefined; const client = new net.Socket(); - const deferred = createDeferred(); - const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', uuid: fakeUuid, }; - - stubExecutionService = ({ - exec: async () => { + mockProc = new MockChildProcess('', ['']); + const output = new Observable>(() => { + /* no op */ + }); + const stubExecutionService2 = ({ + execObservable: () => { client.connect(server.getPort()); - return Promise.resolve({ stdout: '', stderr: '' }); + return { + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + }; }, } as unknown) as IPythonExecutionService; - server = new PythonTestServer(stubExecutionFactory, debugLauncher); + const stubExecutionFactory2 = ({ + createActivatedEnvironment: () => Promise.resolve(stubExecutionService2), + } as unknown) as IPythonExecutionFactory; + + deferred = createDeferred(); + server = new PythonTestServer(stubExecutionFactory2, debugLauncher); await server.serverReady(); server.onDataReceived(({ data }) => { eventData = data; @@ -161,16 +227,17 @@ suite('Python Test Server', () => { console.log('Socket connection error:', error); }); - await server.sendCommand(options); + server.sendCommand(options); + // add in await and trigger await deferred.promise; + mockProc.trigger('close'); + assert.deepStrictEqual(eventData, ''); }); test('If the server doesnt recognize the UUID it should ignore it', async () => { let eventData: string | undefined; const client = new net.Socket(); - const deferred = createDeferred(); - const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), @@ -178,14 +245,28 @@ suite('Python Test Server', () => { uuid: fakeUuid, }; - stubExecutionService = ({ - exec: async () => { + deferred = createDeferred(); + mockProc = new MockChildProcess('', ['']); + const output = new Observable>(() => { + /* no op */ + }); + const stubExecutionService2 = ({ + execObservable: () => { client.connect(server.getPort()); - return Promise.resolve({ stdout: '', stderr: '' }); + return { + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + }; }, } as unknown) as IPythonExecutionService; - server = new PythonTestServer(stubExecutionFactory, debugLauncher); + const stubExecutionFactory2 = ({ + createActivatedEnvironment: () => Promise.resolve(stubExecutionService2), + } as unknown) as IPythonExecutionFactory; + server = new PythonTestServer(stubExecutionFactory2, debugLauncher); await server.serverReady(); server.onDataReceived(({ data }) => { eventData = data; @@ -201,7 +282,7 @@ suite('Python Test Server', () => { console.log('Socket connection error:', error); }); - await server.sendCommand(options); + server.sendCommand(options); await deferred.promise; assert.deepStrictEqual(eventData, ''); }); @@ -212,23 +293,34 @@ suite('Python Test Server', () => { test('Error if payload does not have a content length header', async () => { let eventData: string | undefined; const client = new net.Socket(); - const deferred = createDeferred(); - const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', uuid: fakeUuid, }; - - stubExecutionService = ({ - exec: async () => { + deferred = createDeferred(); + mockProc = new MockChildProcess('', ['']); + const output = new Observable>(() => { + /* no op */ + }); + const stubExecutionService2 = ({ + execObservable: () => { client.connect(server.getPort()); - return Promise.resolve({ stdout: '', stderr: '' }); + return { + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + }; }, } as unknown) as IPythonExecutionService; - server = new PythonTestServer(stubExecutionFactory, debugLauncher); + const stubExecutionFactory2 = ({ + createActivatedEnvironment: () => Promise.resolve(stubExecutionService2), + } as unknown) as IPythonExecutionFactory; + server = new PythonTestServer(stubExecutionFactory2, debugLauncher); await server.serverReady(); server.onDataReceived(({ data }) => { eventData = data; @@ -244,7 +336,7 @@ suite('Python Test Server', () => { console.log('Socket connection error:', error); }); - await server.sendCommand(options); + server.sendCommand(options); await deferred.promise; assert.deepStrictEqual(eventData, ''); }); @@ -267,7 +359,6 @@ Request-uuid: UUID_HERE // Your test logic here let eventData: string | undefined; const client = new net.Socket(); - const deferred = createDeferred(); const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, @@ -275,15 +366,28 @@ Request-uuid: UUID_HERE cwd: '/foo/bar', uuid: fakeUuid, }; - - stubExecutionService = ({ - exec: async () => { + deferred = createDeferred(); + mockProc = new MockChildProcess('', ['']); + const output = new Observable>(() => { + /* no op */ + }); + const stubExecutionService2 = ({ + execObservable: () => { client.connect(server.getPort()); - return Promise.resolve({ stdout: '', stderr: '' }); + return { + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + }; }, } as unknown) as IPythonExecutionService; + const stubExecutionFactory2 = ({ + createActivatedEnvironment: () => Promise.resolve(stubExecutionService2), + } as unknown) as IPythonExecutionFactory; - server = new PythonTestServer(stubExecutionFactory, debugLauncher); + server = new PythonTestServer(stubExecutionFactory2, debugLauncher); await server.serverReady(); const uuid = server.createUUID(); payload = payload.replace('UUID_HERE', uuid); @@ -301,7 +405,7 @@ Request-uuid: UUID_HERE console.log('Socket connection error:', error); }); - await server.sendCommand(options); + server.sendCommand(options); await deferred.promise; assert.deepStrictEqual(eventData, expectedResult); }); @@ -310,8 +414,29 @@ Request-uuid: UUID_HERE test('Calls run resolver if the result header is in the payload', async () => { let eventData: string | undefined; const client = new net.Socket(); - const deferred = createDeferred(); + deferred = createDeferred(); + mockProc = new MockChildProcess('', ['']); + const output = new Observable>(() => { + /* no op */ + }); + const stubExecutionService2 = ({ + execObservable: () => { + client.connect(server.getPort()); + return { + proc: mockProc, + out: output, + dispose: () => { + /* no-body */ + }, + }; + }, + } as unknown) as IPythonExecutionService; + + const stubExecutionFactory2 = ({ + createActivatedEnvironment: () => Promise.resolve(stubExecutionService2), + } as unknown) as IPythonExecutionFactory; + server = new PythonTestServer(stubExecutionFactory2, debugLauncher); const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), @@ -319,14 +444,6 @@ Request-uuid: UUID_HERE uuid: fakeUuid, }; - stubExecutionService = ({ - exec: async () => { - client.connect(server.getPort()); - return Promise.resolve({ stdout: '', stderr: '' }); - }, - } as unknown) as IPythonExecutionService; - - server = new PythonTestServer(stubExecutionFactory, debugLauncher); await server.serverReady(); const uuid = server.createUUID(); server.onRunDataReceived(({ data }) => { @@ -349,9 +466,8 @@ Request-uuid: ${uuid} console.log('Socket connection error:', error); }); - await server.sendCommand(options); + server.sendCommand(options); await deferred.promise; - console.log('event data', eventData); const expectedResult = '{"cwd": "path", "status": "success", "result": "xyz", "not_found": null, "error": null}'; assert.deepStrictEqual(eventData, expectedResult); diff --git a/extensions/positron-python/src/test/testing/testController/workspaceTestAdapter.unit.test.ts b/extensions/positron-python/src/test/testing/testController/workspaceTestAdapter.unit.test.ts index 5a2e4813074..41cd1bbd7ef 100644 --- a/extensions/positron-python/src/test/testing/testController/workspaceTestAdapter.unit.test.ts +++ b/extensions/positron-python/src/test/testing/testController/workspaceTestAdapter.unit.test.ts @@ -164,8 +164,7 @@ suite('Workspace test adapter', () => { const buildErrorNodeOptionsStub = sinon.stub(util, 'buildErrorNodeOptions').returns(errorTestItemOptions); const testProvider = 'unittest'; - const abc = await workspaceTestAdapter.discoverTests(testController); - console.log(abc); + await workspaceTestAdapter.discoverTests(testController); sinon.assert.calledWithMatch(createErrorTestItemStub, sinon.match.any, sinon.match.any); sinon.assert.calledWithMatch(buildErrorNodeOptionsStub, Uri.parse('foo'), sinon.match.any, testProvider); diff --git a/extensions/positron-python/src/testTestingRootWkspc/largeWorkspace/test_parameterized_subtest.py b/extensions/positron-python/src/testTestingRootWkspc/largeWorkspace/test_parameterized_subtest.py new file mode 100644 index 00000000000..3e84df0a2d9 --- /dev/null +++ b/extensions/positron-python/src/testTestingRootWkspc/largeWorkspace/test_parameterized_subtest.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import pytest +import unittest + + +@pytest.mark.parametrize("num", range(0, 200)) +def test_odd_even(num): + return num % 2 == 0 + + +class NumbersTest(unittest.TestCase): + def test_even(self): + for i in range(0, 200): + with self.subTest(i=i): + self.assertEqual(i % 2, 0) diff --git a/extensions/positron-python/src/testTestingRootWkspc/smallWorkspace/test_simple.py b/extensions/positron-python/src/testTestingRootWkspc/smallWorkspace/test_simple.py new file mode 100644 index 00000000000..6b4f7bd2f8a --- /dev/null +++ b/extensions/positron-python/src/testTestingRootWkspc/smallWorkspace/test_simple.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + + +def test_a(): + assert 1 == 1 + + +class SimpleClass(unittest.TestCase): + def test_simple_unit(self): + assert True diff --git a/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts b/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts index d778e53e508..feb491d24f1 100644 --- a/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts +++ b/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts @@ -4,31 +4,34 @@ *--------------------------------------------------------------------------------------------*/ declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/171173 - // https://github.com/microsoft/vscode/issues/182069 + // export interface ExtensionContext { + // /** + // * Gets the extension's global environment variable collection for this workspace, enabling changes to be + // * applied to terminal environment variables. + // */ + // readonly environmentVariableCollection: GlobalEnvironmentVariableCollection; + // } - // export interface ExtensionContext { - // /** - // * Gets the extension's environment variable collection for this workspace, enabling changes - // * to be applied to terminal environment variables. - // * - // * @param scope The scope to which the environment variable collection applies to. - // */ - // readonly environmentVariableCollection: EnvironmentVariableCollection & { getScopedEnvironmentVariableCollection(scope: EnvironmentVariableScope): EnvironmentVariableCollection }; - // } + export interface GlobalEnvironmentVariableCollection extends EnvironmentVariableCollection { + /** + * Gets scope-specific environment variable collection for the extension. This enables alterations to + * terminal environment variables solely within the designated scope, and is applied in addition to (and + * after) the global collection. + * + * Each object obtained through this method is isolated and does not impact objects for other scopes, + * including the global collection. + * + * @param scope The scope to which the environment variable collection applies to. + */ + getScoped(scope: EnvironmentVariableScope): EnvironmentVariableCollection; + } - export type EnvironmentVariableScope = { - /** - * Any specific workspace folder to get collection for. If unspecified, collection applicable to all workspace folders is returned. - */ - workspaceFolder?: WorkspaceFolder; - }; - - export interface EnvironmentVariableCollection extends Iterable<[variable: string, mutator: EnvironmentVariableMutator]> { - /** - * A description for the environment variable collection, this will be used to describe the - * changes in the UI. - */ - description: string | MarkdownString | undefined; - } + // export type EnvironmentVariableScope = { + // /** + // * Any specific workspace folder to get collection for. If unspecified, collection applicable to all workspace folders is returned. + // */ + // workspaceFolder?: WorkspaceFolder; + // }; } diff --git a/extensions/positron-python/yarn.lock b/extensions/positron-python/yarn.lock index 77440635af4..e414f152c90 100644 --- a/extensions/positron-python/yarn.lock +++ b/extensions/positron-python/yarn.lock @@ -786,10 +786,10 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc" integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw== -"@types/vscode@^1.75.0": - version "1.77.0" - resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.77.0.tgz#f92f15a636abc9ef562f44dd8af6766aefedb445" - integrity sha512-MWFN5R7a33n8eJZJmdVlifjig3LWUNRrPeO1xemIcZ0ae0TEQuRc7G2xV0LUX78RZFECY1plYBn+dP/Acc3L0Q== +"@types/vscode@^1.81.0": + version "1.82.0" + resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.82.0.tgz#89b0b21179dcf5e8cee1664a9a05c5f6c60d38d0" + integrity sha512-VSHV+VnpF8DEm8LNrn8OJ8VuUNcBzN3tMvKrNpbhhfuVjFm82+6v44AbDhLvVFgCzn6vs94EJNTp7w8S6+Q1Rw== "@types/which@^2.0.1": version "2.0.1"