diff --git a/extensions/positron-python/.eslintignore b/extensions/positron-python/.eslintignore index e1fe5337706..ad69cab31ea 100644 --- a/extensions/positron-python/.eslintignore +++ b/extensions/positron-python/.eslintignore @@ -48,8 +48,6 @@ src/test/testing/common/services/configSettingService.unit.test.ts src/test/common/exitCIAfterTestReporter.ts -src/test/common/net/fileDownloader.unit.test.ts -src/test/common/net/httpClient.unit.test.ts src/test/common/terminals/activator/index.unit.test.ts src/test/common/terminals/activator/base.unit.test.ts @@ -76,7 +74,6 @@ src/test/common/platform/filesystem.test.ts src/test/common/utils/cacheUtils.unit.test.ts src/test/common/utils/decorators.unit.test.ts -src/test/common/utils/localize.functional.test.ts src/test/common/utils/version.unit.test.ts src/test/common/configSettings/configSettings.unit.test.ts @@ -108,7 +105,6 @@ src/test/common/interpreterPathService.unit.test.ts src/test/pythonFiles/formatting/dummy.ts -src/test/debugger/extension/banner.unit.test.ts src/test/debugger/extension/adapter/adapter.test.ts src/test/debugger/extension/adapter/outdatedDebuggerPrompt.unit.test.ts src/test/debugger/extension/adapter/factory.unit.test.ts @@ -124,7 +120,6 @@ src/test/telemetry/index.unit.test.ts src/test/telemetry/envFileTelemetry.unit.test.ts src/test/application/diagnostics/checks/macPythonInterpreter.unit.test.ts -src/test/application/diagnostics/checks/pythonPathDeprecated.unit.test.ts src/test/application/diagnostics/checks/pythonInterpreter.unit.test.ts src/test/application/diagnostics/checks/invalidLaunchJsonDebugger.unit.test.ts src/test/application/diagnostics/checks/powerShellActivation.unit.test.ts @@ -139,7 +134,6 @@ src/test/performance/load.perf.test.ts src/client/interpreter/configuration/interpreterSelector/commands/base.ts src/client/interpreter/configuration/interpreterSelector/commands/resetInterpreter.ts -src/client/interpreter/configuration/interpreterSelector/commands/setShebangInterpreter.ts src/client/interpreter/configuration/pythonPathUpdaterServiceFactory.ts src/client/interpreter/configuration/services/globalUpdaterService.ts src/client/interpreter/configuration/services/workspaceUpdaterService.ts @@ -177,12 +171,9 @@ src/client/testing/common/runner.ts src/client/common/helpers.ts src/client/common/net/browser.ts -src/client/common/net/fileDownloader.ts -src/client/common/net/httpClient.ts src/client/common/net/socket/socketCallbackHandler.ts src/client/common/net/socket/socketServer.ts src/client/common/net/socket/SocketStream.ts -src/client/common/asyncDisposableRegistry.ts src/client/common/editor.ts src/client/common/contextKey.ts src/client/common/experiments/telemetry.ts @@ -207,10 +198,6 @@ src/client/common/terminal/shellDetectors/vscEnvironmentShellDetector.ts src/client/common/terminal/shellDetectors/terminalNameShellDetector.ts src/client/common/terminal/shellDetectors/settingsShellDetector.ts src/client/common/terminal/shellDetectors/baseShellDetector.ts -src/client/common/terminal/environmentActivationProviders/baseActivationProvider.ts -src/client/common/terminal/environmentActivationProviders/commandPrompt.ts -src/client/common/terminal/environmentActivationProviders/bash.ts -src/client/common/terminal/environmentActivationProviders/pyenvActivationProvider.ts src/client/common/utils/decorators.ts src/client/common/utils/enum.ts src/client/common/utils/platform.ts diff --git a/extensions/positron-python/.github/workflows/build.yml b/extensions/positron-python/.github/workflows/build.yml index f26f13ce50f..805077ffdb4 100644 --- a/extensions/positron-python/.github/workflows/build.yml +++ b/extensions/positron-python/.github/workflows/build.yml @@ -9,7 +9,7 @@ on: - 'release-*' env: - NODE_VERSION: 14.18.2 + NODE_VERSION: 16.17.1 PYTHON_VERSION: '3.10' # YML treats 3.10 the number as 3.1, so quotes around 3.10 # Force a path with spaces and to test extension works in these scenarios # Unicode characters are causing 2.7 failures so skip that for now. diff --git a/extensions/positron-python/.github/workflows/community-feedback-auto-comment.yml b/extensions/positron-python/.github/workflows/community-feedback-auto-comment.yml new file mode 100644 index 00000000000..cbba6db30b5 --- /dev/null +++ b/extensions/positron-python/.github/workflows/community-feedback-auto-comment.yml @@ -0,0 +1,30 @@ +name: Community Feedback Auto Comment + +on: + issues: + types: + - labeled +jobs: + add-comment: + if: github.event.label.name == 'needs community feedback' + runs-on: ubuntu-latest + + permissions: + issues: write + + steps: + - name: Check For Existing Comment + uses: peter-evans/find-comment@v2 + id: finder + with: + issue-number: ${{ github.event.issue.number }} + comment-author: 'github-actions[bot]' + body-includes: Thanks for the feature request! We are going to give the community + + - name: Add Community Feedback Comment + if: steps.finder.outputs.comment-id == '' + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.issue.number }} + body: | + Thanks for the feature request! We are going to give the community 60 days from when this issue was created to provide 7 👍 upvotes on the opening comment to gauge general interest in this idea. If there's enough upvotes then we will consider this feature request in our future planning. If there's unfortunately not enough upvotes then we will close this issue. diff --git a/extensions/positron-python/.github/workflows/pr-check.yml b/extensions/positron-python/.github/workflows/pr-check.yml index ade1dcd6123..2ac560af995 100644 --- a/extensions/positron-python/.github/workflows/pr-check.yml +++ b/extensions/positron-python/.github/workflows/pr-check.yml @@ -8,7 +8,7 @@ on: - release* env: - NODE_VERSION: 14.18.2 + NODE_VERSION: 16.17.1 PYTHON_VERSION: '3.10' # YML treats 3.10 the number as 3.1, so quotes around 3.10 MOCHA_REPORTER_JUNIT: true # Use the mocha-multi-reporters and send output to both console (spec) and JUnit (mocha-junit-reporter). Also enables a reporter which exits the process running the tests if it haven't already. ARTIFACT_NAME_VSIX: ms-python-insiders-vsix diff --git a/extensions/positron-python/.github/workflows/pr-labels.yml b/extensions/positron-python/.github/workflows/pr-labels.yml index 7563d4d44dc..e953f62d201 100644 --- a/extensions/positron-python/.github/workflows/pr-labels.yml +++ b/extensions/positron-python/.github/workflows/pr-labels.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: 'PR impact specified' - uses: mheap/github-action-required-labels@v3 + uses: mheap/github-action-required-labels@v4 with: mode: exactly count: 1 diff --git a/extensions/positron-python/.github/workflows/python27-issue-response.yml b/extensions/positron-python/.github/workflows/python27-issue-response.yml new file mode 100644 index 00000000000..4d51e9921ab --- /dev/null +++ b/extensions/positron-python/.github/workflows/python27-issue-response.yml @@ -0,0 +1,14 @@ +on: + issues: + types: [opened] + +jobs: + python27-issue-response: + runs-on: ubuntu-latest + if: "contains(github.event.issue.body, 'Python version (& distribution if applicable, e.g. Anaconda): 2.7')" + steps: + - name: Check for Python 2.7 string + run: | + response="We're sorry, but we no longer support Python 2.7. If you need to work with Python 2.7, you will have to pin to 2022.2.* version of the extension, which was the last version that had the debugger (debugpy) with support for python 2.7, and was tested with `2.7`. Thank you for your understanding! \n ![https://user-images.githubusercontent.com/51720070/80000627-39dacc00-8472-11ea-9755-ac7ba0acbb70.gif](https://user-images.githubusercontent.com/51720070/80000627-39dacc00-8472-11ea-9755-ac7ba0acbb70.gif)" + gh issue comment ${{ github.event.issue.number }} --body "$response" + gh issue close ${{ github.event.issue.number }} diff --git a/extensions/positron-python/.github/workflows/triage-info-needed.yml b/extensions/positron-python/.github/workflows/triage-info-needed.yml new file mode 100644 index 00000000000..51c5b610f03 --- /dev/null +++ b/extensions/positron-python/.github/workflows/triage-info-needed.yml @@ -0,0 +1,96 @@ +name: Triage "info-needed" label + +on: + issue_comment: + types: [created] + +env: + TRIAGERS: '["karrtikr","karthiknadig","paulacamargo25","eleanorjboyd", "brettcannon"]' + +jobs: + add_label: + runs-on: ubuntu-latest + if: contains(github.event.issue.labels.*.name, 'triage-needed') && !contains(github.event.issue.labels.*.name, 'info-needed') + env: + KEYWORDS: '["\\?", "please", "kindly", "let me know", "try", "can you", "could you", "would you", "may I", "provide", "let us know", "tell me", "give me", "send me", "what", "when", "where", "why", "how"]' + steps: + - name: Check for author + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + const commentAuthor = context.payload.comment.user.login; + const commentBody = context.payload.comment.body; + const isTeamMember = ${{ env.TRIAGERS }}.includes(commentAuthor); + + const keywords = ${{ env.KEYWORDS }}; + const isRequestForInfo = new RegExp(keywords.join('|'), 'i').test(commentBody); + + const shouldAddLabel = isTeamMember && commentAuthor !== issue.data.user.login && isRequestForInfo; + + if (shouldAddLabel) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['info-needed'] + }); + } + + remove_label: + if: contains(github.event.issue.labels.*.name, 'info-needed') && contains(github.event.issue.labels.*.name, 'triage-needed') + runs-on: ubuntu-latest + steps: + - name: Check for author + uses: actions/github-script@v6 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + const commentAuthor = context.payload.comment.user.login; + const issueAuthor = issue.data.user.login; + if (commentAuthor === issueAuthor) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'info-needed' + }); + return; + } + if (${{ env.TRIAGERS }}.includes(commentAuthor)) { + // If one of triagers made a comment, ignore it + return; + } + // Loop through all the comments on the issue in reverse order and find the last username that a TRIAGER mentioned + // If the comment author is the last mentioned username, remove the "info-needed" label + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + for (const comment of comments.data.slice().reverse()) { + if (!${{ env.TRIAGERS }}.includes(comment.user.login)) { + continue; + } + const matches = comment.body.match(/@\w+/g) || []; + const mentionedUsernames = matches.map(match => match.replace('@', '')); + if (mentionedUsernames.includes(commentAuthor)) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + name: 'info-needed' + }); + break; + } + } diff --git a/extensions/positron-python/.vscode/extensions.json b/extensions/positron-python/.vscode/extensions.json index 045d61d3167..5ade8dec488 100644 --- a/extensions/positron-python/.vscode/extensions.json +++ b/extensions/positron-python/.vscode/extensions.json @@ -6,6 +6,7 @@ "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "ms-python.python", + "ms-python.black-formatter", "ms-python.vscode-pylance" ] } diff --git a/extensions/positron-python/.vscode/launch.json b/extensions/positron-python/.vscode/launch.json index 7b654703dbd..82981a93305 100644 --- a/extensions/positron-python/.vscode/launch.json +++ b/extensions/positron-python/.vscode/launch.json @@ -253,6 +253,15 @@ "request": "attach", "listen": { "host": "localhost", "port": 5678 }, "justMyCode": true + }, + { + "name": "Debug pytest plugin tests", + + "type": "python", + "request": "launch", + "module": "pytest", + "args": ["${workspaceFolder}/pythonFiles/tests/pytestadapter"], + "justMyCode": true } ], "compounds": [ diff --git a/extensions/positron-python/build/azure-pipeline.pre-release.yml b/extensions/positron-python/build/azure-pipeline.pre-release.yml index a5b95c91af9..5bc61b2fd55 100644 --- a/extensions/positron-python/build/azure-pipeline.pre-release.yml +++ b/extensions/positron-python/build/azure-pipeline.pre-release.yml @@ -25,7 +25,7 @@ extends: buildSteps: - task: NodeTool@0 inputs: - versionSpec: '14.18.2' + versionSpec: '16.17.1' displayName: Select Node version - task: UsePythonVersion@0 diff --git a/extensions/positron-python/build/azure-pipeline.stable.yml b/extensions/positron-python/build/azure-pipeline.stable.yml index 76e1e0061eb..159d856b6c3 100644 --- a/extensions/positron-python/build/azure-pipeline.stable.yml +++ b/extensions/positron-python/build/azure-pipeline.stable.yml @@ -28,7 +28,7 @@ extends: buildSteps: - task: NodeTool@0 inputs: - versionSpec: '14.18.2' + versionSpec: '16.17.1' displayName: Select Node version - task: UsePythonVersion@0 diff --git a/extensions/positron-python/build/existingFiles.json b/extensions/positron-python/build/existingFiles.json index 0d7e0c3c41c..1f5acc727d8 100644 --- a/extensions/positron-python/build/existingFiles.json +++ b/extensions/positron-python/build/existingFiles.json @@ -379,6 +379,7 @@ "src/test/common/socketStream.test.ts", "src/test/common/terminals/activation.bash.unit.test.ts", "src/test/common/terminals/activation.commandPrompt.unit.test.ts", + "src/test/common/terminals/activation.nushell.unit.test.ts", "src/test/common/terminals/activation.conda.unit.test.ts", "src/test/common/terminals/activation.unit.test.ts", "src/test/common/terminals/activator/base.unit.test.ts", diff --git a/extensions/positron-python/build/webpack/webpack.extension.config.js b/extensions/positron-python/build/webpack/webpack.extension.config.js index dbf74ec0ccc..79a6556d708 100644 --- a/extensions/positron-python/build/webpack/webpack.extension.config.js +++ b/extensions/positron-python/build/webpack/webpack.extension.config.js @@ -64,10 +64,9 @@ const config = { // See: https://github.com/microsoft/vscode-extension-telemetry/issues/41#issuecomment-598852991 'applicationinsights-native-metrics', '@opentelemetry/tracing', - // --- Start Positron --- + '@azure/opentelemetry-instrumentation-azure-sdk', '@opentelemetry/instrumentation', - '@azure/opentelemetry-instrumentation-azure-sdk' - // --- End Positron --- + '@azure/functions-core', ], plugins: [...common.getDefaultPlugins('extension')], resolve: { diff --git a/extensions/positron-python/package.json b/extensions/positron-python/package.json index 0d65afd7be4..9c4c2d79794 100644 --- a/extensions/positron-python/package.json +++ b/extensions/positron-python/package.json @@ -21,7 +21,9 @@ "contribEditorContentMenu", "quickPickSortByLabel", "envShellEvent", - "testObserver" + "testObserver", + "quickPickItemTooltip", + "envCollectionWorkspace" ], "author": { "name": "Microsoft Corporation" @@ -42,7 +44,7 @@ "theme": "dark" }, "engines": { - "vscode": "^1.77.0-20230309" + "vscode": "^1.78.0-20230421" }, "keywords": [ "python", @@ -84,24 +86,24 @@ "walkthroughs": [ { "id": "pythonWelcome", - "title": "Get Started with Python Development", - "description": "Your first steps to set up a Python project with all the powerful tools and features that the Python extension has to offer!", + "title": "%walkthrough.pythonWelcome.title%", + "description": "%walkthrough.pythonWelcome.description%", "when": "workspacePlatform != webworker", "steps": [ { "id": "python.createPythonFile", - "title": "Create a Python file", - "description": "[Open](command:toSide:workbench.action.files.openFile) or [create](command:toSide:workbench.action.files.newUntitledFile?%7B%22languageId%22%3A%22python%22%7D) a Python file - make sure to save it as \".py\".\n[Create Python File](command:toSide:workbench.action.files.newUntitledFile?%7B%22languageId%22%3A%22python%22%7D)", + "title": "%walkthrough.step.python.createPythonFile.title%", + "description": "%walkthrough.step.python.createPythonFile.description%", "media": { "svg": "resources/walkthrough/open-folder.svg", - "altText": "Open a Python file or a folder with a Python project." + "altText": "%walkthrough.step.python.createPythonFile.altText%" }, "when": "" }, { "id": "python.installPythonWin8", - "title": "Install Python", - "description": "The Python Extension requires Python to be installed. Install Python [from python.org](https://www.python.org/downloads).\n\n[Install Python](https://www.python.org/downloads)\n", + "title": "%walkthrough.step.python.installPythonWin8.title%", + "description": "%walkthrough.step.python.installPythonWin8.description%", "media": { "markdown": "resources/walkthrough/install-python-windows-8.md" }, @@ -109,8 +111,8 @@ }, { "id": "python.installPythonMac", - "title": "Install Python", - "description": "The Python Extension requires Python to be installed. Install Python 3 through the terminal.\n[Install Python via Brew](command:python.installPythonOnMac)\n", + "title": "%walkthrough.step.python.installPythonMac.title%", + "description": "%walkthrough.step.python.installPythonMac.description%", "media": { "markdown": "resources/walkthrough/install-python-macos.md" }, @@ -119,8 +121,8 @@ }, { "id": "python.installPythonLinux", - "title": "Install Python", - "description": "The Python Extension requires Python to be installed. Install Python 3 through the terminal.\n[Install Python via terminal](command:python.installPythonOnLinux)\n", + "title": "%walkthrough.step.python.installPythonLinux.title%", + "description": "%walkthrough.step.python.installPythonLinux.description%", "media": { "markdown": "resources/walkthrough/install-python-linux.md" }, @@ -129,40 +131,40 @@ }, { "id": "python.selectInterpreter", - "title": "Select a Python Interpreter", - "description": "Choose which Python interpreter/environment you want to use for your Python project.\n[Select Python Interpreter](command:python.setInterpreter)\n**Tip**: Run the ``Python: Select Interpreter`` command in the [Command Palette](command:workbench.action.showCommands).", + "title": "%walkthrough.step.python.selectInterpreter.title%", + "description": "%walkthrough.step.python.selectInterpreter.description%", "media": { "svg": "resources/walkthrough/python-interpreter.svg", - "altText": "Selecting a python interpreter from the status bar" + "altText": "%walkthrough.step.python.selectInterpreter.altText%" }, "when": "workspaceFolderCount == 0" }, { "id": "python.createEnvironment", - "title": "Create a Python Environment ", - "description": "Create an environment for your Python project.\n[Create Environment](command:python.createEnvironment)\n**Tip**: Run the ``Python: Create Environment`` command in the [Command Palette](command:workbench.action.showCommands).\n 🔍 Check out our [docs](https://aka.ms/pythonenvs) to learn more.", + "title": "%walkthrough.step.python.createEnvironment.title%", + "description": "%walkthrough.step.python.createEnvironment.description%", "media": { "svg": "resources/walkthrough/create-environment.svg", - "altText": "Creating a Python environment from the Command Palette" + "altText": "%walkthrough.step.python.createEnvironment.altText%" }, "when": "workspaceFolderCount > 0" }, { "id": "python.runAndDebug", - "title": "Run and debug your Python file", - "description": "Open your Python file and click on the play button on the top right of the editor, or press F5 when on the file and select \"Python File\" to run with the debugger. \n \n[Learn more](https://code.visualstudio.com/docs/python/python-tutorial#_run-hello-world)", + "title": "%walkthrough.step.python.runAndDebug.title%", + "description": "%walkthrough.step.python.runAndDebug.description%", "media": { "svg": "resources/walkthrough/rundebug2.svg", - "altText": "How to run and debug in VS Code with F5 or the play button on the top right." + "altText": "%walkthrough.step.python.runAndDebug.altText%" }, "when": "" }, { "id": "python.learnMoreWithDS", - "title": "Explore more resources", - "description": "🎨 Explore all the features the Python extension has to offer by looking for \"Python\" in the [Command Palette](command:workbench.action.showCommands). \n 📈 Learn more about getting started with [data science](command:workbench.action.openWalkthrough?%7B%22category%22%3A%22ms-python.python%23pythonDataScienceWelcome%22%2C%22step%22%3A%22ms-python.python%23python.createNewNotebook%22%7D) in Python. \n ✨ Take a look at our [Release Notes](https://aka.ms/AA8dxtb) to learn more about the latest features. \n \n[Learn More](https://aka.ms/AA8dqti)", + "title": "%walkthrough.step.python.learnMoreWithDS.title%", + "description": "%walkthrough.step.python.learnMoreWithDS.description%", "media": { - "altText": "Image representing our documentation page and mailing list resources.", + "altText": "%walkthrough.step.python.learnMoreWithDS.altText%", "svg": "resources/walkthrough/learnmore.svg" }, "when": "" @@ -171,26 +173,26 @@ }, { "id": "pythonDataScienceWelcome", - "title": "Get Started with Python for Data Science", - "description": "Your first steps to getting started with a Data Science project with Python!", + "title": "%walkthrough.pythonDataScienceWelcome.title%", + "description": "%walkthrough.pythonDataScienceWelcome.description%", "when": "false", "steps": [ { "id": "python.installJupyterExt", - "title": "Install Jupyter extension", - "description": "If you haven't already, install the [Jupyter extension](command:workbench.extensions.search?\"ms-toolsai.jupyter\") to take full advantage of notebooks experiences in VS Code!\n \n[Search Jupyter extension](command:workbench.extensions.search?\"ms-toolsai.jupyter\")", + "title": "%walkthrough.step.python.installJupyterExt.title%", + "description": "%walkthrough.step.python.installJupyterExt.description%", "media": { "svg": "resources/walkthrough/data-science.svg", - "altText": "Creating a new Jupyter notebook" + "altText": "%walkthrough.step.python.installJupyterExt.altText%" } }, { "id": "python.createNewNotebook", - "title": "Create or open a Jupyter Notebook", - "description": "Right click in the file explorer and create a new file with an .ipynb extension. Or, open the [Command Palette](command:workbench.action.showCommands) and run the command \n``Jupyter: Create New Blank Notebook``.\n[Create new Jupyter Notebook](command:toSide:jupyter.createnewnotebook)\n If you have an existing project, you can also [open a folder](command:workbench.action.files.openFolder) and/or clone a project from GitHub: [clone a Git repository](command:git.clone).", + "title": "%walkthrough.step.python.createNewNotebook.title%", + "description": "%walkthrough.step.python.createNewNotebook.description%", "media": { "svg": "resources/walkthrough/create-notebook.svg", - "altText": "Creating a new Jupyter notebook" + "altText": "%walkthrough.step.python.createNewNotebook.altText%" }, "completionEvents": [ "onCommand:jupyter.createnewnotebook", @@ -200,11 +202,11 @@ }, { "id": "python.openInteractiveWindow", - "title": "Open the Python Interactive Window", - "description": "The Python Interactive Window is a Python shell where you can execute and view the results of your Python code. You can create cells on a Python file by typing ``#%%``.\n \nTo open the interactive window anytime, open the [Command Palette](command:workbench.action.showCommands) and run the command \n``Jupyter: Create Interactive Window``.\n[Open Interactive Window](command:jupyter.createnewinteractive)", + "title": "%walkthrough.step.python.openInteractiveWindow.title%", + "description": "%walkthrough.step.python.openInteractiveWindow.description%", "media": { "svg": "resources/walkthrough/interactive-window.svg", - "altText": "Opening python interactive window" + "altText": "%walkthrough.step.python.openInteractiveWindow.altText%" }, "completionEvents": [ "onCommand:jupyter.createnewinteractive" @@ -212,11 +214,11 @@ }, { "id": "python.dataScienceLearnMore", - "title": "Find out more!", - "description": "📒 Take a look into the [Jupyter extension](command:workbench.extensions.search?\"ms-toolsai.jupyter\") features, by looking for \"Jupyter\" in the [Command Palette](command:workbench.action.showCommands). \n 🏃🏻 Find out more features in our [Tutorials](https://aka.ms/AAdjzpd). \n[Learn more](https://aka.ms/AAdar6q)", + "title": "%walkthrough.step.python.dataScienceLearnMore.title%", + "description": "%walkthrough.step.python.dataScienceLearnMore.description%", "media": { "svg": "resources/walkthrough/learnmore.svg", - "altText": "Image representing our documentation page and mailing list resources." + "altText": "%walkthrough.step.python.dataScienceLearnMore.altText%" } } ] @@ -276,6 +278,11 @@ "command": "python.createEnvironment", "title": "%python.command.python.createEnvironment.title%" }, + { + "category": "Python", + "command": "python.createEnvironment-button", + "title": "%python.command.python.createEnvironment.title%" + }, { "category": "Python", "command": "python.enableLinting", @@ -375,6 +382,11 @@ "light": "resources/light/repl.svg" }, "title": "%python.command.python.viewOutput.title%" + }, + { + "category": "Python", + "command": "python.installJupyter", + "title": "%python.command.python.installJupyter.title%" } ], "configuration": { @@ -923,6 +935,7 @@ }, "python.logging.level": { "default": "error", + "deprecationMessage": "%python.logging.level.deprecation%", "description": "%python.logging.level.description%", "enum": [ "debug", @@ -1699,16 +1712,41 @@ "editor/content": [ { "group": "Python", - "command": "python.createEnvironment", - "when": "resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported" + "command": "python.createEnvironment-button", + "when": "resourceLangId == pip-requirements && !virtualWorkspace && shellExecutionSupported && !inDiffEditor" }, { "group": "Python", - "command": "python.createEnvironment", - "when": "resourceFilename == pyproject.toml && pipInstallableToml && !virtualWorkspace && shellExecutionSupported" + "command": "python.createEnvironment-button", + "when": "resourceFilename == pyproject.toml && pipInstallableToml && !virtualWorkspace && shellExecutionSupported && !inDiffEditor" } ], "editor/context": [ + { + "submenu": "python.run", + "group": "Python", + "when": "editorLangId == python && !virtualWorkspace && shellExecutionSupported && isWorkspaceTrusted" + }, + { + "command": "python.sortImports", + "group": "Refactor", + "title": "%python.command.python.sortImports.title%", + "when": "editorLangId == python && !notebookEditorFocused && !virtualWorkspace && shellExecutionSupported" + }, + { + "submenu": "python.runFileInteractive", + "group": "Jupyter2", + "when": "editorLangId == python && !virtualWorkspace && shellExecutionSupported && !isJupyterInstalled && isWorkspaceTrusted" + } + ], + "python.runFileInteractive": [ + { + "command": "python.installJupyter", + "group": "Jupyter2", + "when": "resourceLangId == python && !virtualWorkspace && shellExecutionSupported" + } + ], + "python.run": [ { "command": "python.execInTerminal", "group": "Python", @@ -1723,12 +1761,6 @@ "command": "python.execSelectionInTerminal", "group": "Python", "when": "editorFocus && editorLangId == python && !virtualWorkspace && shellExecutionSupported" - }, - { - "command": "python.sortImports", - "group": "Refactor", - "title": "%python.command.python.sortImports.title%", - "when": "editorLangId == python && !notebookEditorFocused && !virtualWorkspace && shellExecutionSupported" } ], "editor/title": [ @@ -1774,6 +1806,17 @@ } ] }, + "submenus": [ + { + "id": "python.run", + "label": "%python.editor.context.submenu.runPython%", + "icon": "$(play)" + }, + { + "id": "python.runFileInteractive", + "label": "%python.editor.context.submenu.runPythonInteractive%" + } + ], "viewsWelcome": [ { "view": "testing", @@ -1835,7 +1878,7 @@ }, "dependencies": { "@iarna/toml": "^2.2.5", - "@vscode/extension-telemetry": "^0.7.4-preview", + "@vscode/extension-telemetry": "^0.7.7", "@vscode/jupyter-lsp-middleware": "^0.2.50", "arch": "^2.1.0", "diff-match-patch": "^1.0.0", @@ -1870,7 +1913,7 @@ "vscode-tas-client": "^0.1.63", "which": "^2.0.2", "winreg": "^1.2.4", - "xml2js": "^0.4.19" + "xml2js": "^0.5.0" }, "devDependencies": { "@istanbuljs/nyc-config-typescript": "^1.0.2", @@ -1886,7 +1929,7 @@ "@types/md5": "^2.1.32", "@types/mocha": "^9.1.0", "@types/nock": "^10.0.3", - "@types/node": "^14.18.0", + "@types/node": "^16.17.0", "@types/semver": "^5.5.0", "@types/shortid": "^0.0.29", "@types/sinon": "^10.0.11", @@ -1894,6 +1937,7 @@ "@types/tmp": "^0.0.33", "@types/uuid": "^8.3.4", "@types/vscode": "^1.75.0", + "@vscode/vsce": "^2.18.0", "@types/which": "^2.0.1", "@types/winreg": "^1.2.30", "@types/xml2js": "0.4.9", @@ -1942,7 +1986,6 @@ "typemoq": "^2.1.0", "typescript": "4.5.5", "uuid": "^8.3.2", - "vsce": "^2.6.6", "vscode-debugadapter-testsupport": "^1.27.0", "webpack": "^5.76.0", "webpack-bundle-analyzer": "^4.5.0", diff --git a/extensions/positron-python/package.nls.json b/extensions/positron-python/package.nls.json index 57323a0ab5e..061cd706420 100644 --- a/extensions/positron-python/package.nls.json +++ b/extensions/positron-python/package.nls.json @@ -12,6 +12,7 @@ "python.command.python.setInterpreter.title": "Select Interpreter", "python.command.python.clearWorkspaceInterpreter.title": "Clear Workspace Interpreter Setting", "python.command.python.viewOutput.title": "Show Output", + "python.command.python.installJupyter.title": "Install the Jupyter extension", "python.command.python.viewLanguageServerOutput.title": "Show Language Server Output", "python.command.python.configureTests.title": "Configure Tests", "python.command.testing.rerunFailedTests.title": "Rerun Failed Tests", @@ -27,6 +28,8 @@ "python.command.python.launchTensorBoard.title": "Launch TensorBoard", "python.command.python.refreshTensorBoard.title": "Refresh TensorBoard", "python.menu.createNewFile.title": "Python File", + "python.editor.context.submenu.runPython": "Run Python", + "python.editor.context.submenu.runPythonInteractive": "Run in Interactive window", "python.activeStateToolPath.description": "Path to the State Tool executable for ActiveState runtimes (version 0.36+).", "python.autoComplete.extraPaths.description": "List of paths to libraries and the like that need to be imported by auto complete engine. E.g. when using Google App SDK, the paths are not in system path, hence need to be added into this list.", "python.condaPath.description": "Path to the conda executable to use for activation (version 4.4+).", @@ -100,6 +103,7 @@ "python.linting.pylintEnabled.description": "Whether to lint Python files using pylint.", "python.linting.pylintPath.description": "Path to Pylint, you can use a custom version of pylint by modifying this setting to include the full path.", "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.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.", @@ -122,5 +126,42 @@ "python.venvFolders.description": "Folders in your home directory to look into for virtual environments (supports pyenv, direnv and virtualenvwrapper by default).", "python.venvPath.description": "Path to folder with a list of Virtual Environments (e.g. ~/.pyenv, ~/Envs, ~/.virtualenvs).", "python.sortImports.args.deprecationMessage": "This setting will be removed soon. Use 'isort.args' instead.", - "python.sortImports.path.deprecationMessage": "This setting will be removed soon. Use 'isort.path' instead." + "python.sortImports.path.deprecationMessage": "This setting will be removed soon. Use 'isort.path' instead.", + "walkthrough.pythonWelcome.title": "Get Started with Python Development", + "walkthrough.pythonWelcome.description": "Your first steps to set up a Python project with all the powerful tools and features that the Python extension has to offer!", + "walkthrough.step.python.createPythonFile.title": "Create a Python file", + "walkthrough.step.python.createPythonFile.description": "[Open](command:toSide:workbench.action.files.openFile) or [create](command:toSide:workbench.action.files.newUntitledFile?%7B%22languageId%22%3A%22python%22%7D) a Python file - make sure to save it as \".py\".\n[Create Python File](command:toSide:workbench.action.files.newUntitledFile?%7B%22languageId%22%3A%22python%22%7D)", + "walkthrough.step.python.installPythonWin8.title": "Install Python", + "walkthrough.step.python.installPythonWin8.description": "The Python Extension requires Python to be installed. Install Python [from python.org](https://www.python.org/downloads).\n\n[Install Python](https://www.python.org/downloads)\n", + "walkthrough.step.python.installPythonMac.title": "Install Python", + "walkthrough.step.python.installPythonMac.description": "The Python Extension requires Python to be installed. Install Python 3 through the terminal.\n[Install Python via Brew](command:python.installPythonOnMac)\n", + "walkthrough.step.python.installPythonLinux.title": "Install Python", + "walkthrough.step.python.installPythonLinux.description": "The Python Extension requires Python to be installed. Install Python 3 through the terminal.\n[Install Python via terminal](command:python.installPythonOnLinux)\n", + "walkthrough.step.python.selectInterpreter.title": "Select a Python Interpreter", + "walkthrough.step.python.selectInterpreter.description": "Choose which Python interpreter/environment you want to use for your Python project.\n[Select Python Interpreter](command:python.setInterpreter)\n**Tip**: Run the ``Python: Select Interpreter`` command in the [Command Palette](command:workbench.action.showCommands).", + "walkthrough.step.python.createEnvironment.title": "Create a Python Environment ", + "walkthrough.step.python.createEnvironment.description": "Create an environment for your Python project.\n[Create Environment](command:python.createEnvironment)\n**Tip**: Run the ``Python: Create Environment`` command in the [Command Palette](command:workbench.action.showCommands).\n 🔍 Check out our [docs](https://aka.ms/pythonenvs) to learn more.", + "walkthrough.step.python.runAndDebug.title": "Run and debug your Python file", + "walkthrough.step.python.runAndDebug.description": "Open your Python file and click on the play button on the top right of the editor, or press F5 when on the file and select \"Python File\" to run with the debugger. \n \n[Learn more](https://code.visualstudio.com/docs/python/python-tutorial#_run-hello-world)", + "walkthrough.step.python.learnMoreWithDS.title": "Explore more resources", + "walkthrough.step.python.learnMoreWithDS.description": "🎨 Explore all the features the Python extension has to offer by looking for \"Python\" in the [Command Palette](command:workbench.action.showCommands). \n 📈 Learn more about getting started with [data science](command:workbench.action.openWalkthrough?%7B%22category%22%3A%22ms-python.python%23pythonDataScienceWelcome%22%2C%22step%22%3A%22ms-python.python%23python.createNewNotebook%22%7D) in Python. \n ✨ Take a look at our [Release Notes](https://aka.ms/AA8dxtb) to learn more about the latest features. \n \n[Learn More](https://aka.ms/AA8dqti)", + "walkthrough.pythonDataScienceWelcome.title": "Get Started with Python for Data Science", + "walkthrough.pythonDataScienceWelcome.description": "Your first steps to getting started with a Data Science project with Python!", + "walkthrough.step.python.installJupyterExt.title": "Install Jupyter extension", + "walkthrough.step.python.installJupyterExt.description": "If you haven't already, install the [Jupyter extension](command:workbench.extensions.search?\"ms-toolsai.jupyter\") to take full advantage of notebooks experiences in VS Code!\n \n[Search Jupyter extension](command:workbench.extensions.search?\"ms-toolsai.jupyter\")", + "walkthrough.step.python.createNewNotebook.title": "Create or open a Jupyter Notebook", + "walkthrough.step.python.createNewNotebook.description": "Right click in the file explorer and create a new file with an .ipynb extension. Or, open the [Command Palette](command:workbench.action.showCommands) and run the command \n``Jupyter: Create New Blank Notebook``.\n[Create new Jupyter Notebook](command:toSide:jupyter.createnewnotebook)\n If you have an existing project, you can also [open a folder](command:workbench.action.files.openFolder) and/or clone a project from GitHub: [clone a Git repository](command:git.clone).", + "walkthrough.step.python.openInteractiveWindow.title": "Open the Python Interactive Window", + "walkthrough.step.python.openInteractiveWindow.description": "The Python Interactive Window is a Python shell where you can execute and view the results of your Python code. You can create cells on a Python file by typing ``#%%``.\n \nTo open the interactive window anytime, open the [Command Palette](command:workbench.action.showCommands) and run the command \n``Jupyter: Create Interactive Window``.\n[Open Interactive Window](command:jupyter.createnewinteractive)", + "walkthrough.step.python.dataScienceLearnMore.title": "Find out more!", + "walkthrough.step.python.dataScienceLearnMore.description": "📒 Take a look into the [Jupyter extension](command:workbench.extensions.search?\"ms-toolsai.jupyter\") features, by looking for \"Jupyter\" in the [Command Palette](command:workbench.action.showCommands). \n 🏃🏻 Find out more features in our [Tutorials](https://aka.ms/AAdjzpd). \n[Learn more](https://aka.ms/AAdar6q)", + "walkthrough.step.python.createPythonFile.altText": "Open a Python file or a folder with a Python project.", + "walkthrough.step.python.selectInterpreter.altText": "Selecting a Python interpreter from the status bar", + "walkthrough.step.python.createEnvironment.altText": "Creating a Python environment from the Command Palette", + "walkthrough.step.python.runAndDebug.altText": "How to run and debug in VS Code with F5 or the play button on the top right.", + "walkthrough.step.python.learnMoreWithDS.altText": "Image representing our documentation page and mailing list resources.", + "walkthrough.step.python.installJupyterExt.altText": "Creating a new Jupyter notebook", + "walkthrough.step.python.createNewNotebook.altText": "Creating a new Jupyter notebook", + "walkthrough.step.python.openInteractiveWindow.altText": "Opening Python interactive window", + "walkthrough.step.python.dataScienceLearnMore.altText": "Image representing our documentation page and mailing list resources." } diff --git a/extensions/positron-python/pythonFiles/create_conda.py b/extensions/positron-python/pythonFiles/create_conda.py index 9a34de47d51..15320a8a1ce 100644 --- a/extensions/positron-python/pythonFiles/create_conda.py +++ b/extensions/positron-python/pythonFiles/create_conda.py @@ -9,7 +9,7 @@ from typing import Optional, Sequence, Union CONDA_ENV_NAME = ".conda" -CWD = pathlib.PurePath(os.getcwd()) +CWD = pathlib.Path.cwd() class VenvError(Exception): diff --git a/extensions/positron-python/pythonFiles/create_microvenv.py b/extensions/positron-python/pythonFiles/create_microvenv.py new file mode 100644 index 00000000000..10eae38ab97 --- /dev/null +++ b/extensions/positron-python/pythonFiles/create_microvenv.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import argparse +import os +import pathlib +import subprocess +import sys +from typing import Optional, Sequence + +VENV_NAME = ".venv" +LIB_ROOT = pathlib.Path(__file__).parent / "lib" / "python" +CWD = pathlib.Path.cwd() + + +class MicroVenvError(Exception): + pass + + +def run_process(args: Sequence[str], error_message: str) -> None: + try: + print("Running: " + " ".join(args)) + subprocess.run(args, cwd=os.getcwd(), check=True) + except subprocess.CalledProcessError: + raise MicroVenvError(error_message) + + +def parse_args(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser() + + parser.add_argument( + "--name", + default=VENV_NAME, + type=str, + help="Name of the virtual environment.", + metavar="NAME", + action="store", + ) + return parser.parse_args(argv) + + +def create_microvenv(name: str): + run_process( + [sys.executable, os.fspath(LIB_ROOT / "microvenv.py"), name], + "CREATE_MICROVENV.MICROVENV_FAILED_CREATION", + ) + + +def main(argv: Optional[Sequence[str]] = None) -> None: + if argv is None: + argv = [] + args = parse_args(argv) + + print("CREATE_MICROVENV.CREATING_MICROVENV") + create_microvenv(args.name) + print("CREATE_MICROVENV.CREATED_MICROVENV") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/extensions/positron-python/pythonFiles/create_venv.py b/extensions/positron-python/pythonFiles/create_venv.py index 2a2768c993a..cac084fd222 100644 --- a/extensions/positron-python/pythonFiles/create_venv.py +++ b/extensions/positron-python/pythonFiles/create_venv.py @@ -7,10 +7,12 @@ import pathlib import subprocess import sys +import urllib.request as url_lib from typing import List, Optional, Sequence, Union VENV_NAME = ".venv" -CWD = pathlib.PurePath(os.getcwd()) +CWD = pathlib.Path.cwd() +MICROVENV_SCRIPT_PATH = pathlib.Path(__file__).parent / "create_microvenv.py" class VenvError(Exception): @@ -125,34 +127,97 @@ def add_gitignore(name: str) -> None: f.write("*") +def download_pip_pyz(name: str): + url = "https://bootstrap.pypa.io/pip/pip.pyz" + print("CREATE_VENV.DOWNLOADING_PIP") + + try: + with url_lib.urlopen(url) as response: + pip_pyz_path = os.fspath(CWD / name / "pip.pyz") + with open(pip_pyz_path, "wb") as out_file: + data = response.read() + out_file.write(data) + out_file.flush() + except Exception: + raise VenvError("CREATE_VENV.DOWNLOAD_PIP_FAILED") + + +def install_pip(name: str): + pip_pyz_path = os.fspath(CWD / name / "pip.pyz") + executable = get_venv_path(name) + print("CREATE_VENV.INSTALLING_PIP") + run_process( + [executable, pip_pyz_path, "install", "pip"], + "CREATE_VENV.INSTALL_PIP_FAILED", + ) + + def main(argv: Optional[Sequence[str]] = None) -> None: if argv is None: argv = [] args = parse_args(argv) - if not is_installed("venv"): - raise VenvError("CREATE_VENV.VENV_NOT_FOUND") - + use_micro_venv = False + venv_installed = is_installed("venv") pip_installed = is_installed("pip") - deps_needed = args.requirements or args.extras or args.toml - if deps_needed and not pip_installed: - raise VenvError("CREATE_VENV.PIP_NOT_FOUND") + ensure_pip_installed = is_installed("ensurepip") + distutils_installed = is_installed("distutils") + + if not venv_installed: + if sys.platform == "win32": + raise VenvError("CREATE_VENV.VENV_NOT_FOUND") + else: + use_micro_venv = True + if not distutils_installed: + print("Install `python3-distutils` package or equivalent for your OS.") + print("On Debian/Ubuntu: `sudo apt install python3-distutils`") + raise VenvError("CREATE_VENV.DISTUTILS_NOT_INSTALLED") if venv_exists(args.name): + # A virtual environment with same name exists. + # We will use the existing virtual environment. venv_path = get_venv_path(args.name) print(f"EXISTING_VENV:{venv_path}") else: - run_process( - [sys.executable, "-m", "venv", args.name], - "CREATE_VENV.VENV_FAILED_CREATION", - ) + if use_micro_venv: + # `venv` was not found but on this platform we can use `microvenv` + run_process( + [ + sys.executable, + os.fspath(MICROVENV_SCRIPT_PATH), + "--name", + args.name, + ], + "CREATE_VENV.MICROVENV_FAILED_CREATION", + ) + elif not pip_installed or not ensure_pip_installed: + # `venv` was found but `pip` or `ensurepip` was not found. + # We create a venv without `pip` in it. We will later install `pip`. + run_process( + [sys.executable, "-m", "venv", "--without-pip", args.name], + "CREATE_VENV.VENV_FAILED_CREATION", + ) + else: + # Both `venv` and `pip` were found. So create a .venv normally + run_process( + [sys.executable, "-m", "venv", args.name], + "CREATE_VENV.VENV_FAILED_CREATION", + ) + venv_path = get_venv_path(args.name) print(f"CREATED_VENV:{venv_path}") + if args.git_ignore: add_gitignore(args.name) - if pip_installed: + # At this point we have a .venv. Now we handle installing `pip`. + if pip_installed and ensure_pip_installed: + # We upgrade pip if it is already installed. upgrade_pip(venv_path) + else: + # `pip` was not found, so we download it and install it. + download_pip_pyz(args.name) + install_pip(args.name) if args.toml: print(f"VENV_INSTALLING_PYPROJECT: {args.toml}") diff --git a/extensions/positron-python/pythonFiles/install_debugpy.py b/extensions/positron-python/pythonFiles/install_debugpy.py index b7f663c0190..cabb620ea1f 100644 --- a/extensions/positron-python/pythonFiles/install_debugpy.py +++ b/extensions/positron-python/pythonFiles/install_debugpy.py @@ -13,7 +13,7 @@ DEBUGGER_DEST = os.path.join(EXTENSION_ROOT, "pythonFiles", "lib", "python") DEBUGGER_PACKAGE = "debugpy" DEBUGGER_PYTHON_ABI_VERSIONS = ("cp310",) -DEBUGGER_VERSION = "1.6.6" # can also be "latest" +DEBUGGER_VERSION = "1.6.7" # can also be "latest" def _contains(s, parts=()): diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py new file mode 100644 index 00000000000..9ac9f7017f8 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +# This test's id is double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function. +# This test passes. +def test_function(): # test_marker--test_function + assert 1 == 1 diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/dual_level_nested_folder/nested_folder_one/test_bottom_folder.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/dual_level_nested_folder/nested_folder_one/test_bottom_folder.py new file mode 100644 index 00000000000..59738aeba37 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/dual_level_nested_folder/nested_folder_one/test_bottom_folder.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +# This test's id is dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t. +# This test passes. +def test_bottom_function_t(): # test_marker--test_bottom_function_t + assert True + + +# This test's id is dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f. +# This test fails. +def test_bottom_function_f(): # test_marker--test_bottom_function_f + assert False diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/dual_level_nested_folder/test_top_folder.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/dual_level_nested_folder/test_top_folder.py new file mode 100644 index 00000000000..010c54cf446 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/dual_level_nested_folder/test_top_folder.py @@ -0,0 +1,14 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +# This test's id is dual_level_nested_folder/test_top_folder.py::test_top_function_t. +# This test passes. +def test_top_function_t(): # test_marker--test_top_function_t + assert True + + +# This test's id is dual_level_nested_folder/test_top_folder.py::test_top_function_f. +# This test fails. +def test_top_function_f(): # test_marker--test_top_function_f + assert False diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/empty_discovery.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/empty_discovery.py new file mode 100644 index 00000000000..5f4ea27aec7 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/empty_discovery.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +# This file has no tests in it; the discovery will return an empty list of tests. +def function_function(string): + return string diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/error_parametrize_discovery.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/error_parametrize_discovery.py new file mode 100644 index 00000000000..8e48224edf3 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/error_parametrize_discovery.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import pytest + + +# This test has an error which will appear on pytest discovery. +# This error is intentional and is meant to test pytest discovery error handling. +@pytest.mark.parametrize("actual,expected", [("3+5", 8), ("2+4", 6), ("6*9", 42)]) +def test_function(): + assert True diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/error_syntax_discovery.txt b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/error_syntax_discovery.txt new file mode 100644 index 00000000000..78627fffb35 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/error_syntax_discovery.txt @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +# This test has a syntax error. +# This error is intentional and is meant to test pytest discovery error handling. +def test_function() + assert True diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/parametrize_tests.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/parametrize_tests.py new file mode 100644 index 00000000000..9421e0cc069 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/parametrize_tests.py @@ -0,0 +1,10 @@ +import pytest + + +# Testing pytest with parametrized tests. The first two pass, the third fails. +# The tests ids are parametrize_tests.py::test_adding[3+5-8] and so on. +@pytest.mark.parametrize( # test_marker--test_adding + "actual, expected", [("3+5", 8), ("2+4", 6), ("6+9", 16)] +) +def test_adding(actual, expected): + assert eval(actual) == expected diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/simple_pytest.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/simple_pytest.py new file mode 100644 index 00000000000..9f9bfb014f3 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/simple_pytest.py @@ -0,0 +1,7 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + + +# This test passes. +def test_function(): # test_marker--test_function + assert 1 == 1 diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/text_docstring.txt b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/text_docstring.txt new file mode 100644 index 00000000000..b29132c10b5 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/text_docstring.txt @@ -0,0 +1,4 @@ +This is a doctest test which passes #test_marker--text_docstring.txt +>>> x = 3 +>>> x +3 diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_folder/test_add.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_folder/test_add.py new file mode 100644 index 00000000000..a96c7f2fa39 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_folder/test_add.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + + +def add(a, b): + return a + b + + +class TestAddFunction(unittest.TestCase): + # This test's id is unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers. + # This test passes. + def test_add_positive_numbers(self): # test_marker--test_add_positive_numbers + result = add(2, 3) + self.assertEqual(result, 5) + + # This test's id is unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers. + # This test passes. + def test_add_negative_numbers(self): # test_marker--test_add_negative_numbers + result = add(-2, -3) + self.assertEqual(result, -5) diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_folder/test_subtract.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_folder/test_subtract.py new file mode 100644 index 00000000000..087e5140def --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_folder/test_subtract.py @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + + +def subtract(a, b): + return a - b + + +class TestSubtractFunction(unittest.TestCase): + # This test's id is unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers. + # This test passes. + def test_subtract_positive_numbers( # test_marker--test_subtract_positive_numbers + self, + ): + result = subtract(5, 3) + self.assertEqual(result, 2) + + # This test's id is unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers. + # This test passes. + def test_subtract_negative_numbers( # test_marker--test_subtract_negative_numbers + self, + ): + result = subtract(-2, -3) + # This is intentional to test assertion failures + self.assertEqual(result, 100000) diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_pytest_same_file.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_pytest_same_file.py new file mode 100644 index 00000000000..ac66779b9cb --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/.data/unittest_pytest_same_file.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + + +class TestExample(unittest.TestCase): + # This test's id is unittest_pytest_same_file.py::TestExample::test_true_unittest. + # Test type is unittest and this test passes. + def test_true_unittest(self): # test_marker--test_true_unittest + assert True + + +# This test's id is unittest_pytest_same_file.py::test_true_pytest. +# Test type is pytest and this test passes. +def test_true_pytest(): # test_marker--test_true_pytest + assert True diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/__init__.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/__init__.py new file mode 100644 index 00000000000..5b7f7a925cc --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. 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 new file mode 100644 index 00000000000..8e96d109ba7 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_discovery_test_output.py @@ -0,0 +1,475 @@ +import os +import pathlib + +from .helpers import TEST_DATA_PATH, find_test_line_number + +# This file contains the expected output dictionaries for tests discovery and is used in test_discovery.py. + +# This is the expected output for the empty_discovery.py file. +# └── +TEST_DATA_PATH_STR = os.fspath(TEST_DATA_PATH) +empty_discovery_pytest_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [], + "id_": TEST_DATA_PATH_STR, +} + +# 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_discovery_pytest_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "simple_pytest.py", + "path": simple_test_file_path, + "type_": "file", + "id_": simple_test_file_path, + "children": [ + { + "name": "test_function", + "path": 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_": TEST_DATA_PATH_STR, +} + +# This is the expected output for the unittest_pytest_same_file.py file. +# ├── unittest_pytest_same_file.py +# ├── 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_discovery_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "unittest_pytest_same_file.py", + "path": unit_pytest_same_file_path, + "type_": "file", + "id_": unit_pytest_same_file_path, + "children": [ + { + "name": "TestExample", + "path": unit_pytest_same_file_path, + "type_": "class", + "children": [ + { + "name": "test_true_unittest", + "path": unit_pytest_same_file_path, + "lineno": find_test_line_number( + "test_true_unittest", + 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_": "unittest_pytest_same_file.py::TestExample", + }, + { + "name": "test_true_pytest", + "path": 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_": TEST_DATA_PATH_STR, +} + +# This is the expected output for the unittest_folder tests +# └── unittest_folder +# ├── test_add.py +# │ └── TestAddFunction +# │ ├── test_add_negative_numbers +# │ └── test_add_positive_numbers +# └── test_subtract.py +# └── TestSubtractFunction +# ├── test_subtract_negative_numbers +# └── test_subtract_positive_numbers +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_discovery_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "unittest_folder", + "path": unittest_folder_path, + "type_": "folder", + "id_": unittest_folder_path, + "children": [ + { + "name": "test_add.py", + "path": test_add_path, + "type_": "file", + "id_": test_add_path, + "children": [ + { + "name": "TestAddFunction", + "path": test_add_path, + "type_": "class", + "children": [ + { + "name": "test_add_negative_numbers", + "path": test_add_path, + "lineno": find_test_line_number( + "test_add_negative_numbers", + 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", + }, + { + "name": "test_add_positive_numbers", + "path": test_add_path, + "lineno": find_test_line_number( + "test_add_positive_numbers", + 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_": "unittest_folder/test_add.py::TestAddFunction", + } + ], + }, + { + "name": "test_subtract.py", + "path": test_subtract_path, + "type_": "file", + "id_": test_subtract_path, + "children": [ + { + "name": "TestSubtractFunction", + "path": test_subtract_path, + "type_": "class", + "children": [ + { + "name": "test_subtract_negative_numbers", + "path": test_subtract_path, + "lineno": find_test_line_number( + "test_subtract_negative_numbers", + 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", + }, + { + "name": "test_subtract_positive_numbers", + "path": test_subtract_path, + "lineno": find_test_line_number( + "test_subtract_positive_numbers", + 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_": "unittest_folder/test_subtract.py::TestSubtractFunction", + } + ], + }, + ], + } + ], + "id_": TEST_DATA_PATH_STR, +} + +# This is the expected output for the dual_level_nested_folder tests +# └── dual_level_nested_folder +# └── test_top_folder.py +# └── test_top_function_t +# └── test_top_function_f +# └── nested_folder_one +# └── 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( + TEST_DATA_PATH / "dual_level_nested_folder" / "test_top_folder.py" +) +test_nested_folder_one_path = os.fspath( + TEST_DATA_PATH / "dual_level_nested_folder" / "nested_folder_one" +) +test_bottom_folder_path = os.fspath( + 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, + "type_": "folder", + "children": [ + { + "name": "dual_level_nested_folder", + "path": dual_level_nested_folder_path, + "type_": "folder", + "id_": dual_level_nested_folder_path, + "children": [ + { + "name": "test_top_folder.py", + "path": test_top_folder_path, + "type_": "file", + "id_": test_top_folder_path, + "children": [ + { + "name": "test_top_function_t", + "path": 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", + }, + { + "name": "test_top_function_f", + "path": 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", + }, + ], + }, + { + "name": "nested_folder_one", + "path": test_nested_folder_one_path, + "type_": "folder", + "id_": test_nested_folder_one_path, + "children": [ + { + "name": "test_bottom_folder.py", + "path": test_bottom_folder_path, + "type_": "file", + "id_": test_bottom_folder_path, + "children": [ + { + "name": "test_bottom_function_t", + "path": 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", + }, + { + "name": "test_bottom_function_f", + "path": 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_": TEST_DATA_PATH_STR, +} + +# This is the expected output for the double_nested_folder tests. +# └── double_nested_folder +# └── nested_folder_one +# └── nested_folder_two +# └── test_nest.py +# └── test_function +double_nested_folder_path = os.fspath(TEST_DATA_PATH / "double_nested_folder") +double_nested_folder_one_path = os.fspath( + TEST_DATA_PATH / "double_nested_folder" / "nested_folder_one" +) +double_nested_folder_two_path = os.fspath( + TEST_DATA_PATH / "double_nested_folder" / "nested_folder_one" / "nested_folder_two" +) +double_nested_test_nest_path = os.fspath( + TEST_DATA_PATH + / "double_nested_folder" + / "nested_folder_one" + / "nested_folder_two" + / "test_nest.py" +) +double_nested_folder_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "double_nested_folder", + "path": double_nested_folder_path, + "type_": "folder", + "id_": double_nested_folder_path, + "children": [ + { + "name": "nested_folder_one", + "path": double_nested_folder_one_path, + "type_": "folder", + "id_": double_nested_folder_one_path, + "children": [ + { + "name": "nested_folder_two", + "path": double_nested_folder_two_path, + "type_": "folder", + "id_": double_nested_folder_two_path, + "children": [ + { + "name": "test_nest.py", + "path": double_nested_test_nest_path, + "type_": "file", + "id_": double_nested_test_nest_path, + "children": [ + { + "name": "test_function", + "path": double_nested_test_nest_path, + "lineno": find_test_line_number( + "test_function", + double_nested_test_nest_path, + ), + "type_": "test", + "id_": "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function", + "runID": "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function", + } + ], + } + ], + } + ], + } + ], + } + ], + "id_": TEST_DATA_PATH_STR, +} + +# This is the expected output for the nested_folder tests. +# └── parametrize_tests.py +# └── test_adding[3+5-8] +# └── test_adding[2+4-6] +# └── test_adding[6+9-16] +parameterize_tests_path = os.fspath(TEST_DATA_PATH / "parametrize_tests.py") +parametrize_tests_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "parametrize_tests.py", + "path": parameterize_tests_path, + "type_": "file", + "id_": parameterize_tests_path, + "children": [ + { + "name": "test_adding[3+5-8]", + "path": 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]", + }, + { + "name": "test_adding[2+4-6]", + "path": 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]", + }, + { + "name": "test_adding[6+9-16]", + "path": 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_": TEST_DATA_PATH_STR, +} + +# 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") +doctest_pytest_expected_output = { + "name": ".data", + "path": TEST_DATA_PATH_STR, + "type_": "folder", + "children": [ + { + "name": "text_docstring.txt", + "path": text_docstring_path, + "type_": "file", + "id_": text_docstring_path, + "children": [ + { + "name": "text_docstring.txt", + "path": text_docstring_path, + "lineno": find_test_line_number( + "text_docstring.txt", + text_docstring_path, + ), + "type_": "test", + "id_": "text_docstring.txt::text_docstring.txt", + "runID": "text_docstring.txt::text_docstring.txt", + } + ], + } + ], + "id_": TEST_DATA_PATH_STR, +} 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 new file mode 100644 index 00000000000..a894403c7d7 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/expected_execution_test_output.py @@ -0,0 +1,328 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +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 +# ├── test_add.py +# │ └── TestAddFunction +# │ ├── test_add_negative_numbers: success +# │ └── test_add_positive_numbers: success +# └── test_subtract.py +# └── TestSubtractFunction +# ├── test_subtract_negative_numbers: failure +# └── test_subtract_positive_numbers: success +uf_execution_expected_output = { + f"{TEST_ADD_FUNCTION}test_add_negative_numbers": { + "test": f"{TEST_ADD_FUNCTION}test_add_negative_numbers", + "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", + "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", + "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", + "outcome": SUCCESS, + "message": None, + "traceback": None, + "subtest": None, + }, +} + + +# This is the expected output for the unittest_folder only execute add.py tests +# └── unittest_folder +# ├── test_add.py +# │ └── TestAddFunction +# │ ├── test_add_negative_numbers: success +# │ └── test_add_positive_numbers: success +uf_single_file_expected_output = { + f"{TEST_ADD_FUNCTION}test_add_negative_numbers": { + "test": f"{TEST_ADD_FUNCTION}test_add_negative_numbers", + "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", + "outcome": SUCCESS, + "message": None, + "traceback": None, + "subtest": None, + }, +} + +# 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", + "outcome": SUCCESS, + "message": None, + "traceback": None, + "subtest": None, + } +} + +# This is the expected output for the unittest_folder tests run where two tests +# run are in different files. +# └── unittest_folder +# ├── test_add.py +# │ └── TestAddFunction +# │ └── test_add_positive_numbers: success +# └── test_subtract.py +# └── TestSubtractFunction +# └── test_subtract_positive_numbers: success +uf_non_adjacent_tests_execution_expected_output = { + TEST_SUBTRACT_FUNCTION + + "test_subtract_positive_numbers": { + "test": TEST_SUBTRACT_FUNCTION + "test_subtract_positive_numbers", + "outcome": SUCCESS, + "message": None, + "traceback": None, + "subtest": None, + }, + TEST_ADD_FUNCTION + + "test_add_positive_numbers": { + "test": TEST_ADD_FUNCTION + "test_add_positive_numbers", + "outcome": SUCCESS, + "message": None, + "traceback": None, + "subtest": None, + }, +} + +# This is the expected output for the simple_pytest.py file. +# └── simple_pytest.py +# └── test_function: success +simple_execution_pytest_expected_output = { + "simple_pytest.py::test_function": { + "test": "simple_pytest.py::test_function", + "outcome": "success", + "message": None, + "traceback": None, + "subtest": None, + } +} + +# 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_execution_expected_output = { + "unittest_pytest_same_file.py::TestExample::test_true_unittest": { + "test": "unittest_pytest_same_file.py::TestExample::test_true_unittest", + "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", + "outcome": "success", + "message": None, + "traceback": None, + "subtest": None, + }, +} + +# This is the expected output for the dual_level_nested_folder.py tests +# └── dual_level_nested_folder +# └── test_top_folder.py +# └── test_top_function_t: success +# └── test_top_function_f: failure +# └── nested_folder_one +# └── test_bottom_folder.py +# └── test_bottom_function_t: success +# └── test_bottom_function_f: failure +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", + "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", + "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", + "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", + "outcome": "failure", + "message": "ERROR MESSAGE", + "traceback": None, + "subtest": None, + }, +} + +# This is the expected output for the nested_folder tests. +# └── nested_folder_one +# └── nested_folder_two +# └── test_nest.py +# └── test_function: success +double_nested_folder_expected_execution_output = { + "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function": { + "test": "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function", + "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_expected_execution_output = { + "parametrize_tests.py::test_adding[3+5-8]": { + "test": "parametrize_tests.py::test_adding[3+5-8]", + "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]", + "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]", + "outcome": "failure", + "message": "ERROR MESSAGE", + "traceback": None, + "subtest": None, + }, +} + +# This is the expected output for the single parameterized tests. +# └── 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]", + "outcome": "success", + "message": None, + "traceback": None, + "subtest": None, + }, +} + +# This is the expected output for the single parameterized tests. +# └── text_docstring.txt +# └── text_docstring: success +doctest_pytest_expected_execution_output = { + "text_docstring.txt::text_docstring.txt": { + "test": "text_docstring.txt::text_docstring.txt", + "outcome": "success", + "message": None, + "traceback": None, + "subtest": None, + } +} + +# Will run all tests in the cwd that fit the test file naming pattern. +no_test_ids_pytest_execution_expected_output = { + "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function": { + "test": "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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 new file mode 100644 index 00000000000..b078439f6ea --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/helpers.py @@ -0,0 +1,161 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import contextlib +import io +import json +import os +import pathlib +import random +import socket +import subprocess +import sys +import uuid +from typing import Any, Dict, List, Optional, Union + +TEST_DATA_PATH = pathlib.Path(__file__).parent / ".data" +from typing_extensions import TypedDict + + +@contextlib.contextmanager +def test_output_file(root: pathlib.Path, ext: str = ".txt"): + """Creates a temporary python file with a random name.""" + basename = ( + "".join(random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(9)) + ext + ) + fullpath = root / basename + try: + fullpath.write_text("", encoding="utf-8") + yield fullpath + finally: + os.unlink(str(fullpath)) + + +def create_server( + host: str = "127.0.0.1", + port: int = 0, + backlog: int = socket.SOMAXCONN, + timeout: int = 1000, +) -> socket.socket: + """Return a local server socket listening on the given port.""" + server: socket.socket = _new_sock() + if port: + # If binding to a specific port, make sure that the user doesn't have + # to wait until the OS times out waiting for socket in order to use + # that port again if the server or the adapter crash or are force-killed. + if sys.platform == "win32": + server.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1) + else: + try: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except (AttributeError, OSError): + pass # Not available everywhere + server.bind((host, port)) + if timeout: + server.settimeout(timeout) + server.listen(backlog) + return server + + +def _new_sock() -> socket.socket: + sock: socket.socket = socket.socket( + socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP + ) + options = [ + ("SOL_SOCKET", "SO_KEEPALIVE", 1), + ("IPPROTO_TCP", "TCP_KEEPIDLE", 1), + ("IPPROTO_TCP", "TCP_KEEPINTVL", 3), + ("IPPROTO_TCP", "TCP_KEEPCNT", 5), + ] + + for level, name, value in options: + try: + sock.setsockopt(getattr(socket, level), getattr(socket, name), value) + except (AttributeError, OSError): + pass # May not be available everywhere. + + return sock + + +CONTENT_LENGTH: str = "Content-Length:" +Env_Dict = TypedDict( + "Env_Dict", {"TEST_UUID": str, "TEST_PORT": str, "PYTHONPATH": str} +) + + +def process_rpc_json(data: str) -> Dict[str, Any]: + """Process the JSON data which comes from the server which runs the pytest discovery.""" + str_stream: io.StringIO = io.StringIO(data) + + length: int = 0 + + while True: + line: str = str_stream.readline() + if CONTENT_LENGTH.lower() in line.lower(): + length = int(line[len(CONTENT_LENGTH) :]) + break + + if not line or line.isspace(): + raise ValueError("Header does not contain Content-Length") + + while True: + line: str = str_stream.readline() + if not line or line.isspace(): + break + + raw_json: str = str_stream.read(length) + return json.loads(raw_json) + + +def runner(args: List[str]) -> Optional[Dict[str, Any]]: + """Run the pytest discovery and return the JSON data from the server.""" + process_args: List[str] = [ + sys.executable, + "-m", + "pytest", + "-p", + "vscode_pytest", + ] + args + + with test_output_file(TEST_DATA_PATH) as output_path: + env = os.environ.copy() + env.update( + { + "TEST_UUID": str(uuid.uuid4()), + "TEST_PORT": str(12345), # port is not used for tests + "PYTHONPATH": os.fspath(pathlib.Path(__file__).parent.parent.parent), + "TEST_OUTPUT_FILE": os.fspath(output_path), + } + ) + + result = subprocess.run( + process_args, + env=env, + cwd=os.fspath(TEST_DATA_PATH), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + print("Subprocess Run failed with:") + print(result.stdout.decode(encoding="utf-8")) + print(result.stderr.decode(encoding="utf-8")) + + return process_rpc_json(output_path.read_text(encoding="utf-8")) + + +def find_test_line_number(test_name: str, test_file_path) -> str: + """Function which finds the correct line number for a test by looking for the "test_marker--[test_name]" string. + + The test_name is split on the "[" character to remove the parameterization information. + + Args: + test_name: The name of the test to find the line number for, will be unique per file. + test_file_path: The path to the test file where the test is located. + """ + test_file_unique_id: str = "test_marker--" + test_name.split("[")[0] + with open(test_file_path) as f: + for i, line in enumerate(f): + if test_file_unique_id in line: + return str(i + 1) + error_str: str = f"Test {test_name!r} not found on any line in {test_file_path}" + raise ValueError(error_str) diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py new file mode 100644 index 00000000000..bb6e7255704 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_discovery.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +import shutil + +import pytest + +from . import expected_discovery_test_output +from .helpers import TEST_DATA_PATH, runner + + +def test_syntax_error(tmp_path): + """Test pytest discovery on a file that has a syntax error. + + Copies the contents of a .txt file to a .py file in the temporary directory + to then run pytest discovery on. + + The json should still be returned but the errors list should be present. + + Keyword arguments: + tmp_path -- pytest fixture that creates a temporary directory. + """ + # Saving some files as .txt to avoid that file displaying a syntax error for + # the extension as a whole. Instead, rename it before running this test + # in order to test the error handling. + file_path = TEST_DATA_PATH / "error_syntax_discovery.txt" + temp_dir = tmp_path / "temp_data" + temp_dir.mkdir() + p = temp_dir / "error_syntax_discovery.py" + shutil.copyfile(file_path, p) + actual = runner(["--collect-only", os.fspath(p)]) + assert actual + assert all(item in actual for item in ("status", "cwd", "error")) + assert actual["status"] == "error" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert len(actual["error"]) == 2 + + +def test_parameterized_error_collect(): + """Tests pytest discovery on specific file that incorrectly uses parametrize. + + The json should still be returned but the errors list should be present. + """ + file_path_str = "error_parametrize_discovery.py" + actual = runner(["--collect-only", file_path_str]) + assert actual + assert all(item in actual for item in ("status", "cwd", "error")) + assert actual["status"] == "error" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert len(actual["error"]) == 2 + + +@pytest.mark.parametrize( + "file, expected_const", + [ + ( + "parametrize_tests.py", + expected_discovery_test_output.parametrize_tests_expected_output, + ), + ( + "empty_discovery.py", + expected_discovery_test_output.empty_discovery_pytest_expected_output, + ), + ( + "simple_pytest.py", + expected_discovery_test_output.simple_discovery_pytest_expected_output, + ), + ( + "unittest_pytest_same_file.py", + expected_discovery_test_output.unit_pytest_same_file_discovery_expected_output, + ), + ( + "unittest_folder", + expected_discovery_test_output.unittest_folder_discovery_expected_output, + ), + ( + "dual_level_nested_folder", + expected_discovery_test_output.dual_level_nested_folder_expected_output, + ), + ( + "double_nested_folder", + expected_discovery_test_output.double_nested_folder_expected_output, + ), + ( + "text_docstring.txt", + expected_discovery_test_output.doctest_pytest_expected_output, + ), + ], +) +def test_pytest_collect(file, expected_const): + """ + Test to test pytest discovery on a variety of test files/ folder structures. + Uses variables from expected_discovery_test_output.py to store the expected dictionary return. + Only handles discovery and therefore already contains the arg --collect-only. + All test discovery will succeed, be in the correct cwd, and match expected test output. + + Keyword arguments: + file -- a string with the file or folder to run pytest discovery on. + expected_const -- the expected output from running pytest discovery on the file. + """ + actual = runner( + [ + "--collect-only", + os.fspath(TEST_DATA_PATH / file), + ] + ) + 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) + assert actual["tests"] == expected_const diff --git a/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py new file mode 100644 index 00000000000..8613deb9609 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/pytestadapter/test_execution.py @@ -0,0 +1,165 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +import shutil + +import pytest +from tests.pytestadapter import expected_execution_test_output + +from .helpers import TEST_DATA_PATH, runner + + +def test_syntax_error_execution(tmp_path): + """Test pytest execution on a file that has a syntax error. + + Copies the contents of a .txt file to a .py file in the temporary directory + to then run pytest exeuction on. + + The json should still be returned but the errors list should be present. + + Keyword arguments: + tmp_path -- pytest fixture that creates a temporary directory. + """ + # Saving some files as .txt to avoid that file displaying a syntax error for + # the extension as a whole. Instead, rename it before running this test + # in order to test the error handling. + file_path = TEST_DATA_PATH / "error_syntax_discovery.txt" + temp_dir = tmp_path / "temp_data" + temp_dir.mkdir() + p = temp_dir / "error_syntax_discovery.py" + shutil.copyfile(file_path, p) + actual = runner(["error_syntax_discover.py::test_function"]) + assert actual + assert all(item in actual for item in ("status", "cwd", "error")) + assert actual["status"] == "error" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert len(actual["error"]) == 1 + + +def test_bad_id_error_execution(): + """Test pytest discovery with a non-existent test_id. + + The json should still be returned but the errors list should be present. + """ + actual = runner(["not/a/real::test_id"]) + assert actual + assert all(item in actual for item in ("status", "cwd", "error")) + assert actual["status"] == "error" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert len(actual["error"]) == 1 + + +@pytest.mark.parametrize( + "test_ids, expected_const", + [ + ( + [ + "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_negative_numbers", + ], + expected_execution_test_output.uf_execution_expected_output, + ), + ( + [ + "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + "unittest_folder/test_add.py::TestAddFunction::test_add_negative_numbers", + ], + expected_execution_test_output.uf_single_file_expected_output, + ), + ( + [ + "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + ], + expected_execution_test_output.uf_single_method_execution_expected_output, + ), + ( + [ + "unittest_folder/test_add.py::TestAddFunction::test_add_positive_numbers", + "unittest_folder/test_subtract.py::TestSubtractFunction::test_subtract_positive_numbers", + ], + expected_execution_test_output.uf_non_adjacent_tests_execution_expected_output, + ), + ( + [ + "unittest_pytest_same_file.py::TestExample::test_true_unittest", + "unittest_pytest_same_file.py::test_true_pytest", + ], + expected_execution_test_output.unit_pytest_same_file_execution_expected_output, + ), + ( + [ + "dual_level_nested_folder/test_top_folder.py::test_top_function_t", + "dual_level_nested_folder/test_top_folder.py::test_top_function_f", + "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_t", + "dual_level_nested_folder/nested_folder_one/test_bottom_folder.py::test_bottom_function_f", + ], + expected_execution_test_output.dual_level_nested_folder_execution_expected_output, + ), + ( + [ + "double_nested_folder/nested_folder_one/nested_folder_two/test_nest.py::test_function" + ], + expected_execution_test_output.double_nested_folder_expected_execution_output, + ), + ( + [ + "parametrize_tests.py::test_adding[3+5-8]", + "parametrize_tests.py::test_adding[2+4-6]", + "parametrize_tests.py::test_adding[6+9-16]", + ], + expected_execution_test_output.parametrize_tests_expected_execution_output, + ), + ( + [ + "parametrize_tests.py::test_adding[3+5-8]", + ], + expected_execution_test_output.single_parametrize_tests_expected_execution_output, + ), + ( + [ + "text_docstring.txt::text_docstring.txt", + ], + expected_execution_test_output.doctest_pytest_expected_execution_output, + ), + ( + [ + "", + ], + expected_execution_test_output.no_test_ids_pytest_execution_expected_output, + ), + ], +) +def test_pytest_execution(test_ids, expected_const): + """ + Test that pytest discovery works as expected where run pytest is always successful + but the actual test results are both successes and failures.: + 1. uf_execution_expected_output: unittest tests run on multiple files. + 2. uf_single_file_expected_output: test run on a single file. + 3. uf_single_method_execution_expected_output: test run on a single method in a file. + 4. uf_non_adjacent_tests_execution_expected_output: test run on unittests in two files with single selection in test explorer. + 5. unit_pytest_same_file_execution_expected_output: test run on a file with both unittest and pytest tests. + 6. dual_level_nested_folder_execution_expected_output: test run on a file with one test file at the top level and one test file in a nested folder. + 7. double_nested_folder_expected_execution_output: test run on a double nested folder. + 8. parametrize_tests_expected_execution_output: test run on a parametrize test with 3 inputs. + 9. single_parametrize_tests_expected_execution_output: test run on single parametrize test. + 10. doctest_pytest_expected_execution_output: test run on doctest file. + 11. no_test_ids_pytest_execution_expected_output: test run with no inputted test ids. + + + 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. + """ + args = test_ids + actual = runner(args) + assert actual + assert all(item in actual for item in ("status", "cwd", "result")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + result_data = actual["result"] + for key in result_data: + if result_data[key]["outcome"] == "failure": + result_data[key]["message"] = "ERROR MESSAGE" + assert result_data == expected_const diff --git a/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py b/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py new file mode 100644 index 00000000000..f123052c491 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/test_create_microvenv.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import importlib +import os +import sys + +import create_microvenv +import pytest + + +def test_create_microvenv(): + importlib.reload(create_microvenv) + run_process_called = False + + def run_process(args, error_message): + nonlocal run_process_called + run_process_called = True + assert args == [ + sys.executable, + os.fspath(create_microvenv.LIB_ROOT / "microvenv.py"), + create_microvenv.VENV_NAME, + ] + assert error_message == "CREATE_MICROVENV.MICROVENV_FAILED_CREATION" + + create_microvenv.run_process = run_process + + create_microvenv.main() + assert run_process_called == True diff --git a/extensions/positron-python/pythonFiles/tests/test_create_venv.py b/extensions/positron-python/pythonFiles/tests/test_create_venv.py index 95ec863373d..bebe304c13c 100644 --- a/extensions/positron-python/pythonFiles/tests/test_create_venv.py +++ b/extensions/positron-python/pythonFiles/tests/test_create_venv.py @@ -2,32 +2,51 @@ # Licensed under the MIT License. import importlib +import os import sys import create_venv import pytest -def test_venv_not_installed(): +@pytest.mark.skipif( + sys.platform == "win32", reason="Windows does not have micro venv fallback." +) +def test_venv_not_installed_unix(): importlib.reload(create_venv) create_venv.is_installed = lambda module: module != "venv" - with pytest.raises(create_venv.VenvError) as e: - create_venv.main() - assert str(e.value) == "CREATE_VENV.VENV_NOT_FOUND" + run_process_called = False + def run_process(args, error_message): + nonlocal run_process_called + microvenv_path = os.fspath(create_venv.MICROVENV_SCRIPT_PATH) + if microvenv_path in args: + run_process_called = True + assert args == [ + sys.executable, + microvenv_path, + "--name", + ".test_venv", + ] + assert error_message == "CREATE_VENV.MICROVENV_FAILED_CREATION" + + create_venv.run_process = run_process -@pytest.mark.parametrize("install", ["requirements", "toml"]) -def test_pip_not_installed(install): + create_venv.main(["--name", ".test_venv"]) + + # run_process is called when the venv does not exist + assert run_process_called == True + + +@pytest.mark.skipif( + sys.platform != "win32", reason="Windows does not have microvenv fallback." +) +def test_venv_not_installed_windows(): importlib.reload(create_venv) - create_venv.venv_exists = lambda _n: True - create_venv.is_installed = lambda module: module != "pip" - create_venv.run_process = lambda _args, _error_message: None + create_venv.is_installed = lambda module: module != "venv" with pytest.raises(create_venv.VenvError) as e: - if install == "requirements": - create_venv.main(["--requirements", "requirements-for-test.txt"]) - elif install == "toml": - create_venv.main(["--toml", "pyproject.toml", "--extras", "test"]) - assert str(e.value) == "CREATE_VENV.PIP_NOT_FOUND" + create_venv.main() + assert str(e.value) == "CREATE_VENV.VENV_NOT_FOUND" @pytest.mark.parametrize("env_exists", ["hasEnv", "noEnv"]) @@ -174,3 +193,33 @@ def run_process(args, error_message): create_venv.install_requirements(sys.executable, extras) assert actual == expected + + +def test_create_venv_missing_pip(): + importlib.reload(create_venv) + create_venv.venv_exists = lambda _n: True + create_venv.is_installed = lambda module: module != "pip" + + download_pip_pyz_called = False + + def download_pip_pyz(name): + nonlocal download_pip_pyz_called + download_pip_pyz_called = True + assert name == create_venv.VENV_NAME + + create_venv.download_pip_pyz = download_pip_pyz + + run_process_called = False + + def run_process(args, error_message): + if "install" in args and "pip" in args: + nonlocal run_process_called + run_process_called = True + pip_pyz_path = os.fspath( + create_venv.CWD / create_venv.VENV_NAME / "pip.pyz" + ) + assert args[1:] == [pip_pyz_path, "install", "pip"] + assert error_message == "CREATE_VENV.INSTALL_PIP_FAILED" + + create_venv.run_process = run_process + create_venv.main([]) diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_fail_simple.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_fail_simple.py new file mode 100644 index 00000000000..e329c3fd700 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_fail_simple.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + +# Test class for the test_fail_simple test. +# The test_failed_tests function should return a dictionary with a "success" status +# and the two tests with their outcome as "failed". + +class RunFailSimple(unittest.TestCase): + """Test class for the test_fail_simple test. + + The test_failed_tests function should return a dictionary with a "success" status + and the two tests with their outcome as "failed". + """ + + def test_one_fail(self) -> None: + self.assertGreater(2, 3) + + def test_two_fail(self) -> None: + self.assertNotEqual(1, 1) diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_subtest.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_subtest.py new file mode 100644 index 00000000000..b913b877370 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_subtest.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + +# Test class for the test_subtest_run test. +# The test_failed_tests function should return a dictionary that has a "success" status +# and the "result" value is a dict with 6 entries, one for each subtest. + + +class NumbersTest(unittest.TestCase): + def test_even(self): + """ + Test that numbers between 0 and 5 are all even. + """ + for i in range(0, 6): + with self.subTest(i=i): + self.assertEqual(i % 2, 0) diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_two_classes.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_two_classes.py new file mode 100644 index 00000000000..60b26706ad4 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/test_two_classes.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import unittest + +# Test class which runs for the test_multiple_ids_run test with the two class parameters. +# Both test functions will be returned in a dictionary with a "success" status, +# and the two tests with their outcome as "success". + + +class ClassOne(unittest.TestCase): + + def test_one(self) -> None: + self.assertGreater(2, 1) + +class ClassTwo(unittest.TestCase): + + def test_two(self) -> None: + self.assertGreater(2, 1) + diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/two_patterns/pattern_a_test.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/two_patterns/pattern_a_test.py new file mode 100644 index 00000000000..4f3f77e1056 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/two_patterns/pattern_a_test.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +# Test class for the two file pattern test. It is pattern *test.py. +# The test_ids_multiple_runs function should return a dictionary with a "success" status, +# and the two tests with their outcome as "success". + + + +class DiscoveryA(unittest.TestCase): + """Test class for the two file pattern test. It is pattern *test.py + + The test_ids_multiple_runs function should return a dictionary with a "success" status, + and the two tests with their outcome as "success". + """ + + def test_one_a(self) -> None: + self.assertGreater(2, 1) + + def test_two_a(self) -> None: + self.assertNotEqual(2, 1) \ No newline at end of file diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/two_patterns/test_pattern_b.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/two_patterns/test_pattern_b.py new file mode 100644 index 00000000000..a912699383c --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/two_patterns/test_pattern_b.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +# Test class for the two file pattern test. This file is pattern test*.py. +# The test_ids_multiple_runs function should return a dictionary with a "success" status, +# and the two tests with their outcome as "success". + +class DiscoveryB(unittest.TestCase): + + def test_one_b(self) -> None: + self.assertGreater(2, 1) + + def test_two_b(self) -> None: + self.assertNotEqual(2, 1) \ No newline at end of file diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_folder/test_add.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_folder/test_add.py new file mode 100644 index 00000000000..2e616077ec4 --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_folder/test_add.py @@ -0,0 +1,22 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +# Test class which runs for the test_multiple_ids_run test with the two test +# files in the same folder. The cwd is set to the parent folder. This should return +# a dictionary with a "success" status and the two tests with their outcome as "success". + +def add(a, b): + return a + b + + +class TestAddFunction(unittest.TestCase): + + def test_add_positive_numbers(self): + result = add(2, 3) + self.assertEqual(result, 5) + + + def test_add_negative_numbers(self): + result = add(-2, -3) + self.assertEqual(result, -5) \ No newline at end of file diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_folder/test_subtract.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_folder/test_subtract.py new file mode 100644 index 00000000000..4028e25825d --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/.data/unittest_folder/test_subtract.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +# Test class which runs for the test_multiple_ids_run test with the two test +# files in the same folder. The cwd is set to the parent folder. This should return +# a dictionary with a "success" status and the two tests with their outcome as "success". + +def subtract(a, b): + return a - b + + +class TestSubtractFunction(unittest.TestCase): + def test_subtract_positive_numbers(self): + result = subtract(5, 3) + self.assertEqual(result, 2) + + + def test_subtract_negative_numbers(self): + result = subtract(-2, -3) + self.assertEqual(result, 1) \ No newline at end of file diff --git a/extensions/positron-python/pythonFiles/tests/unittestadapter/test_execution.py b/extensions/positron-python/pythonFiles/tests/unittestadapter/test_execution.py new file mode 100644 index 00000000000..7f58049a56b --- /dev/null +++ b/extensions/positron-python/pythonFiles/tests/unittestadapter/test_execution.py @@ -0,0 +1,283 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os +import pathlib +from typing import List + +import pytest +from unittestadapter.execution import parse_execution_cli_args, run_tests + +TEST_DATA_PATH = pathlib.Path(__file__).parent / ".data" + + +@pytest.mark.parametrize( + "args, expected", + [ + ( + [ + "--port", + "111", + "--uuid", + "fake-uuid", + "--testids", + "test_file.test_class.test_method", + ], + (111, "fake-uuid", ["test_file.test_class.test_method"]), + ), + ( + ["--port", "111", "--uuid", "fake-uuid", "--testids", ""], + (111, "fake-uuid", [""]), + ), + ( + [ + "--port", + "111", + "--uuid", + "fake-uuid", + "--testids", + "test_file.test_class.test_method", + "-v", + "-s", + ], + (111, "fake-uuid", ["test_file.test_class.test_method"]), + ), + ], +) +def test_parse_execution_cli_args(args: List[str], expected: List[str]) -> None: + """The parse_execution_cli_args function should return values for the port, uuid, and testids arguments + when passed as command-line options, and ignore unrecognized arguments. + """ + actual = parse_execution_cli_args(args) + assert actual == expected + + +def test_no_ids_run() -> None: + """This test runs on an empty array of test_ids, therefore it should return + an empty dict for the result. + """ + start_dir: str = os.fspath(TEST_DATA_PATH) + testids = [] + pattern = "discovery_simple*" + actual = run_tests(start_dir, testids, pattern, None, "fake-uuid") + assert actual + assert all(item in actual for item in ("cwd", "status")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + if "result" in actual: + assert len(actual["result"]) == 0 + else: + raise AssertionError("actual['result'] is None") + + +def test_single_ids_run() -> None: + """This test runs on a single test_id, therefore it should return + a dict with a single key-value pair for the result. + + This single test passes so the outcome should be 'success'. + """ + id = "discovery_simple.DiscoverySimple.test_one" + actual = run_tests( + os.fspath(TEST_DATA_PATH), [id], "discovery_simple*", None, "fake-uuid" + ) + assert actual + assert all(item in actual for item in ("cwd", "status")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert "result" in actual + result = actual["result"] + assert len(result) == 1 + assert id in result + id_result = result[id] + assert id_result is not None + assert "outcome" in id_result + assert id_result["outcome"] == "success" + + +def test_subtest_run() -> None: + """This test runs on a the test_subtest which has a single method, test_even, + that uses unittest subtest. + + The actual result of run should return a dict payload with 6 entry for the 6 subtests. + """ + id = "test_subtest.NumbersTest.test_even" + actual = run_tests( + os.fspath(TEST_DATA_PATH), [id], "test_subtest.py", None, "fake-uuid" + ) + subtests_ids = [ + "test_subtest.NumbersTest.test_even (i=0)", + "test_subtest.NumbersTest.test_even (i=1)", + "test_subtest.NumbersTest.test_even (i=2)", + "test_subtest.NumbersTest.test_even (i=3)", + "test_subtest.NumbersTest.test_even (i=4)", + "test_subtest.NumbersTest.test_even (i=5)", + ] + assert actual + assert all(item in actual for item in ("cwd", "status")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert "result" in actual + result = actual["result"] + assert len(result) == 6 + for id in subtests_ids: + assert id in result + + +@pytest.mark.parametrize( + "test_ids, pattern, cwd, expected_outcome", + [ + ( + [ + "test_add.TestAddFunction.test_add_negative_numbers", + "test_add.TestAddFunction.test_add_positive_numbers", + ], + "test_add.py", + os.fspath(TEST_DATA_PATH / "unittest_folder"), + "success", + ), + ( + [ + "test_add.TestAddFunction.test_add_negative_numbers", + "test_add.TestAddFunction.test_add_positive_numbers", + "test_subtract.TestSubtractFunction.test_subtract_negative_numbers", + "test_subtract.TestSubtractFunction.test_subtract_positive_numbers", + ], + "test*", + os.fspath(TEST_DATA_PATH / "unittest_folder"), + "success", + ), + ( + [ + "pattern_a_test.DiscoveryA.test_one_a", + "pattern_a_test.DiscoveryA.test_two_a", + ], + "*test", + os.fspath(TEST_DATA_PATH / "two_patterns"), + "success", + ), + ( + [ + "test_pattern_b.DiscoveryB.test_one_b", + "test_pattern_b.DiscoveryB.test_two_b", + ], + "test_*", + os.fspath(TEST_DATA_PATH / "two_patterns"), + "success", + ), + ( + [ + "file_one.CaseTwoFileOne.test_one", + "file_one.CaseTwoFileOne.test_two", + "folder.file_two.CaseTwoFileTwo.test_one", + "folder.file_two.CaseTwoFileTwo.test_two", + ], + "*", + os.fspath(TEST_DATA_PATH / "utils_nested_cases"), + "success", + ), + ( + [ + "test_two_classes.ClassOne.test_one", + "test_two_classes.ClassTwo.test_two", + ], + "test_two_classes.py", + os.fspath(TEST_DATA_PATH), + "success", + ), + ], +) +def test_multiple_ids_run(test_ids, pattern, cwd, expected_outcome) -> None: + """ + The following are all successful tests of different formats. + + # 1. Two tests with the `pattern` specified as a file + # 2. Two test files in the same folder called `unittest_folder` + # 3. A folder with two different test file patterns, this test gathers pattern `*test` + # 4. A folder with two different test file patterns, this test gathers pattern `test_*` + # 5. A nested structure where a test file is on the same level as a folder containing a test file + # 6. Test file with two test classes + + All tests should have the outcome of `success`. + """ + actual = run_tests(cwd, test_ids, pattern, None, "fake-uuid") + assert actual + assert all(item in actual for item in ("cwd", "status")) + assert actual["status"] == "success" + assert actual["cwd"] == cwd + assert "result" in actual + result = actual["result"] + assert len(result) == len(test_ids) + for test_id in test_ids: + assert test_id in result + id_result = result[test_id] + assert id_result is not None + assert "outcome" in id_result + assert id_result["outcome"] == expected_outcome + assert True + + +def test_failed_tests(): + """This test runs on a single file `test_fail` with two tests that fail.""" + test_ids = [ + "test_fail_simple.RunFailSimple.test_one_fail", + "test_fail_simple.RunFailSimple.test_two_fail", + ] + actual = run_tests( + os.fspath(TEST_DATA_PATH), test_ids, "test_fail_simple*", None, "fake-uuid" + ) + assert actual + assert all(item in actual for item in ("cwd", "status")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert "result" in actual + result = actual["result"] + assert len(result) == len(test_ids) + for test_id in test_ids: + assert test_id in result + id_result = result[test_id] + assert id_result is not None + assert "outcome" in id_result + assert id_result["outcome"] == "failure" + assert "message" and "traceback" in id_result + assert True + + +def test_unknown_id(): + """This test runs on a unknown test_id, therefore it should return + an error as the outcome as it attempts to find the given test. + """ + test_ids = ["unknown_id"] + actual = run_tests( + os.fspath(TEST_DATA_PATH), test_ids, "test_fail_simple*", None, "fake-uuid" + ) + assert actual + assert all(item in actual for item in ("cwd", "status")) + assert actual["status"] == "success" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH) + assert "result" in actual + result = actual["result"] + assert len(result) == len(test_ids) + assert "unittest.loader._FailedTest.unknown_id" in result + id_result = result["unittest.loader._FailedTest.unknown_id"] + assert id_result is not None + assert "outcome" in id_result + assert id_result["outcome"] == "error" + assert "message" and "traceback" in id_result + + +def test_incorrect_path(): + """This test runs on a non existent path, therefore it should return + an error as the outcome as it attempts to find the given folder. + """ + test_ids = ["unknown_id"] + actual = run_tests( + os.fspath(TEST_DATA_PATH / "unknown_folder"), + test_ids, + "test_fail_simple*", + None, + "fake-uuid", + ) + assert actual + assert all(item in actual for item in ("cwd", "status", "error")) + assert actual["status"] == "error" + assert actual["cwd"] == os.fspath(TEST_DATA_PATH / "unknown_folder") diff --git a/extensions/positron-python/pythonFiles/unittestadapter/discovery.py b/extensions/positron-python/pythonFiles/unittestadapter/discovery.py index 0be09e986ca..dc0a139ed5a 100644 --- a/extensions/positron-python/pythonFiles/unittestadapter/discovery.py +++ b/extensions/positron-python/pythonFiles/unittestadapter/discovery.py @@ -8,7 +8,7 @@ import sys import traceback import unittest -from typing import List, Literal, Optional, Tuple, TypedDict, Union +from typing import List, Literal, Optional, Tuple, Union # Add the path to pythonFiles to sys.path to find testing_tools.socket_manager. PYTHON_FILES = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -22,7 +22,7 @@ # Add the lib path to sys.path to find the typing_extensions module. sys.path.insert(0, os.path.join(PYTHON_FILES, "lib", "python")) -from typing_extensions import NotRequired +from typing_extensions import NotRequired, TypedDict DEFAULT_PORT = "45454" @@ -121,13 +121,16 @@ def discover_tests( # Build the request data (it has to be a POST request or the Node side will not process it), and send it. addr = ("localhost", port) - with socket_manager.SocketManager(addr) as s: - data = json.dumps(payload) - request = f"""POST / HTTP/1.1 -Host: localhost:{port} -Content-Length: {len(data)} + data = json.dumps(payload) + request = f"""Content-Length: {len(data)} Content-Type: application/json Request-uuid: {uuid} {data}""" - result = s.socket.sendall(request.encode("utf-8")) # type: ignore + try: + with socket_manager.SocketManager(addr) as s: + if s.socket is not None: + s.socket.sendall(request.encode("utf-8")) + except Exception as e: + print(f"Error sending response: {e}") + print(f"Request data: {request}") diff --git a/extensions/positron-python/pythonFiles/unittestadapter/execution.py b/extensions/positron-python/pythonFiles/unittestadapter/execution.py index a926bcdcc09..37288651f53 100644 --- a/extensions/positron-python/pythonFiles/unittestadapter/execution.py +++ b/extensions/positron-python/pythonFiles/unittestadapter/execution.py @@ -9,7 +9,7 @@ import traceback import unittest from types import TracebackType -from typing import Dict, List, Optional, Tuple, Type, TypeAlias, TypedDict +from typing import Dict, List, Optional, Tuple, Type, Union # Add the path to pythonFiles to sys.path to find testing_tools.socket_manager. PYTHON_FILES = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -17,13 +17,15 @@ # Add the lib path to sys.path to find the typing_extensions module. sys.path.insert(0, os.path.join(PYTHON_FILES, "lib", "python")) from testing_tools import socket_manager -from typing_extensions import NotRequired +from typing_extensions import NotRequired, TypeAlias, TypedDict from unittestadapter.utils import parse_unittest_args DEFAULT_PORT = "45454" -def parse_execution_cli_args(args: List[str]) -> Tuple[int, str | None, List[str]]: +def parse_execution_cli_args( + args: List[str], +) -> Tuple[int, Union[str, None], List[str]]: """Parse command-line arguments that should be processed by the script. So far this includes the port number that it needs to connect to, the uuid passed by the TS side, @@ -43,9 +45,9 @@ def parse_execution_cli_args(args: List[str]) -> Tuple[int, str | None, List[str return (int(parsed_args.port), parsed_args.uuid, parsed_args.testids) -ErrorType = ( - Tuple[Type[BaseException], BaseException, TracebackType] | Tuple[None, None, None] -) +ErrorType = Union[ + Tuple[Type[BaseException], BaseException, TracebackType], Tuple[None, None, None] +] class TestOutcomeEnum(str, enum.Enum): @@ -60,7 +62,9 @@ class TestOutcomeEnum(str, enum.Enum): class UnittestTestResult(unittest.TextTestResult): - formatted: Dict[str, Dict[str, str | None]] = dict() + def __init__(self, *args, **kwargs): + self.formatted: Dict[str, Dict[str, Union[str, None]]] = dict() + super(UnittestTestResult, self).__init__(*args, **kwargs) def startTest(self, test: unittest.TestCase): super(UnittestTestResult, self).startTest(test) @@ -98,7 +102,10 @@ def addUnexpectedSuccess(self, test: unittest.TestCase): self.formatResult(test, TestOutcomeEnum.unexpected_success) def addSubTest( - self, test: unittest.TestCase, subtest: unittest.TestCase, err: ErrorType | None + self, + test: unittest.TestCase, + subtest: unittest.TestCase, + err: Union[ErrorType, None], ): super(UnittestTestResult, self).addSubTest(test, subtest, err) self.formatResult( @@ -112,8 +119,8 @@ def formatResult( self, test: unittest.TestCase, outcome: str, - error: ErrorType | None = None, - subtest: unittest.TestCase | None = None, + error: Union[ErrorType, None] = None, + subtest: Union[unittest.TestCase, None] = None, ): tb = None if error and error[2] is not None: @@ -123,7 +130,10 @@ def formatResult( formatted = formatted[1:] tb = "".join(formatted) - test_id = test.id() + if subtest: + test_id = subtest.id() + else: + test_id = test.id() result = { "test": test.id(), @@ -141,7 +151,7 @@ class TestExecutionStatus(str, enum.Enum): success = "success" -TestResultTypeAlias: TypeAlias = Dict[str, Dict[str, str | None]] +TestResultTypeAlias: TypeAlias = Dict[str, Dict[str, Union[str, None]]] class PayloadDict(TypedDict): @@ -201,11 +211,11 @@ def run_tests( if error is not None: payload["error"] = error + else: + status = TestExecutionStatus.success payload["status"] = status - # print(f"payload: \n{json.dumps(payload, indent=4)}") - return payload @@ -222,13 +232,16 @@ def run_tests( # Build the request data (it has to be a POST request or the Node side will not process it), and send it. addr = ("localhost", port) - with socket_manager.SocketManager(addr) as s: - data = json.dumps(payload) - request = f"""POST / HTTP/1.1 -Host: localhost:{port} -Content-Length: {len(data)} + data = json.dumps(payload) + request = f"""Content-Length: {len(data)} Content-Type: application/json Request-uuid: {uuid} {data}""" - result = s.socket.sendall(request.encode("utf-8")) # type: ignore + try: + with socket_manager.SocketManager(addr) as s: + if s.socket is not None: + s.socket.sendall(request.encode("utf-8")) + except Exception as e: + print(f"Error sending response: {e}") + print(f"Request data: {request}") diff --git a/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py b/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py new file mode 100644 index 00000000000..6063e4113d5 --- /dev/null +++ b/extensions/positron-python/pythonFiles/vscode_pytest/__init__.py @@ -0,0 +1,492 @@ +import json +import os +import pathlib +import sys +import traceback + +import pytest + +script_dir = pathlib.Path(__file__).parent.parent +sys.path.append(os.fspath(script_dir)) +sys.path.append(os.fspath(script_dir / "lib" / "python")) + +from typing import Any, Dict, List, Optional, Union + +from testing_tools import socket_manager +from typing_extensions import Literal, TypedDict + + +class TestData(TypedDict): + """A general class that all test objects inherit from.""" + + name: str + path: str + type_: Literal["class", "file", "folder", "test", "error"] + id_: str + + +class TestItem(TestData): + """A class defining test items.""" + + lineno: str + runID: str + + +class TestNode(TestData): + """A general class that handles all test data which contains children.""" + + children: "list[Union[TestNode, TestItem, None]]" + + +class VSCodePytestError(Exception): + """A custom exception class for pytest errors.""" + + def __init__(self, message): + super().__init__(message) + + +ERRORS = [] + + +def pytest_internalerror(excrepr, excinfo): + """A pytest hook that is called when an internal error occurs. + + Keyword arguments: + excrepr -- the exception representation. + excinfo -- the exception information of type ExceptionInfo. + """ + # call.excinfo.exconly() returns the exception as a string. + ERRORS.append(excinfo.exconly()) + + +def pytest_exception_interact(node, call, report): + """A pytest hook that is called when an exception is raised which could be handled. + + Keyword arguments: + node -- the node that raised the exception. + call -- the call object. + report -- the report object of either type CollectReport or TestReport. + """ + # call.excinfo is the captured exception of the call, if it raised as type ExceptionInfo. + # call.excinfo.exconly() returns the exception as a string. + if call.excinfo and call.excinfo.typename != "AssertionError": + ERRORS.append(call.excinfo.exconly()) + + +def pytest_keyboard_interrupt(excinfo): + """A pytest hook that is called when a keyboard interrupt is raised. + + Keyword arguments: + excinfo -- the exception information of type ExceptionInfo. + """ + # The function execonly() returns the exception as a string. + ERRORS.append(excinfo.exconly()) + + +class TestOutcome(Dict): + """A class that handles outcome for a single test. + + for pytest the outcome for a test is only 'passed', 'skipped' or 'failed' + """ + + test: str + outcome: Literal["success", "failure", "skipped"] + message: Union[str, None] + traceback: Union[str, None] + subtest: Optional[str] + + +def create_test_outcome( + test: str, + outcome: str, + message: Union[str, None], + traceback: Union[str, None], + subtype: Optional[str] = None, +) -> TestOutcome: + """A function that creates a TestOutcome object.""" + return TestOutcome( + test=test, + outcome=outcome, + message=message, + traceback=traceback, # TODO: traceback + subtest=None, + ) + + +class testRunResultDict(Dict[str, Dict[str, TestOutcome]]): + """A class that stores all test run results.""" + + outcome: str + tests: Dict[str, TestOutcome] + + +collected_tests = testRunResultDict() +IS_DISCOVERY = False + + +def pytest_load_initial_conftests(early_config, parser, args): + if "--collect-only" in args: + global IS_DISCOVERY + IS_DISCOVERY = True + + +def pytest_report_teststatus(report, config): + """ + A pytest hook that is called when a test is called. It is called 3 times per test, + during setup, call, and teardown. + Keyword arguments: + report -- the report on the test setup, call, and teardown. + config -- configuration object. + """ + + if report.when == "call": + traceback = None + message = None + report_value = "skipped" + if report.passed: + report_value = "success" + elif report.failed: + report_value = "failure" + message = report.longreprtext + item_result = create_test_outcome( + report.nodeid, + report_value, + message, + traceback, + ) + collected_tests[report.nodeid] = item_result + + +ERROR_MESSAGE_CONST = { + 2: "Pytest was unable to start or run any tests due to issues with test discovery or test collection.", + 3: "Pytest was interrupted by the user, for example by pressing Ctrl+C during test execution.", + 4: "Pytest encountered an internal error or exception during test execution.", + 5: "Pytest was unable to find any tests to run.", +} + + +def pytest_sessionfinish(session, exitstatus): + """A pytest hook that is called after pytest has fulled finished. + + Keyword arguments: + session -- the pytest session object. + exitstatus -- the status code of the session. + + 0: All tests passed successfully. + 1: One or more tests failed. + 2: Pytest was unable to start or run any tests due to issues with test discovery or test collection. + 3: Pytest was interrupted by the user, for example by pressing Ctrl+C during test execution. + 4: Pytest encountered an internal error or exception during test execution. + 5: Pytest was unable to find any tests to run. + """ + cwd = pathlib.Path.cwd() + if IS_DISCOVERY: + try: + session_node: Union[TestNode, None] = build_test_tree(session) + if not session_node: + raise VSCodePytestError( + "Something went wrong following pytest finish, \ + no session node was created" + ) + post_response(os.fsdecode(cwd), session_node) + except Exception as e: + ERRORS.append( + f"Error Occurred, traceback: {(traceback.format_exc() if e.__traceback__ else '')}" + ) + errorNode: TestNode = { + "name": "", + "path": "", + "type_": "error", + "children": [], + "id_": "", + } + post_response(os.fsdecode(cwd), errorNode) + else: + if exitstatus == 0 or exitstatus == 1: + exitstatus_bool = "success" + else: + ERRORS.append( + f"Pytest exited with error status: {exitstatus}, {ERROR_MESSAGE_CONST[exitstatus]}" + ) + exitstatus_bool = "error" + + execution_post( + os.fsdecode(cwd), + exitstatus_bool, + collected_tests if collected_tests else None, + ) + + +def build_test_tree(session: pytest.Session) -> TestNode: + """Builds a tree made up of testing nodes from the pytest session. + + Keyword arguments: + session -- the pytest session object. + """ + session_node = create_session_node(session) + session_children_dict: Dict[str, TestNode] = {} + file_nodes_dict: Dict[Any, TestNode] = {} + class_nodes_dict: Dict[str, TestNode] = {} + + for test_case in session.items: + test_node = create_test_node(test_case) + if isinstance(test_case.parent, pytest.Class): + try: + test_class_node = class_nodes_dict[test_case.parent.name] + except KeyError: + test_class_node = create_class_node(test_case.parent) + class_nodes_dict[test_case.parent.name] = test_class_node + test_class_node["children"].append(test_node) + if test_case.parent.parent: + parent_module = test_case.parent.parent + else: + ERRORS.append(f"Test class {test_case.parent} has no parent") + break + # Create a file node that has the class as a child. + try: + test_file_node: TestNode = file_nodes_dict[parent_module] + except KeyError: + test_file_node = create_file_node(parent_module) + file_nodes_dict[parent_module] = test_file_node + # Check if the class is already a child of the file node. + if test_class_node not in test_file_node["children"]: + test_file_node["children"].append(test_class_node) + else: # This includes test cases that are pytest functions or a doctests. + try: + parent_test_case = file_nodes_dict[test_case.parent] + except KeyError: + parent_test_case = create_file_node(test_case.parent) + file_nodes_dict[test_case.parent] = parent_test_case + parent_test_case["children"].append(test_node) + created_files_folders_dict: Dict[str, TestNode] = {} + for file_module, file_node in file_nodes_dict.items(): + # Iterate through all the files that exist and construct them into nested folders. + root_folder_node: TestNode = build_nested_folders( + file_module, file_node, created_files_folders_dict, session + ) + # The final folder we get to is the highest folder in the path + # and therefore we add this as a child to the session. + root_id = root_folder_node.get("id_") + if root_id and root_id not in session_children_dict: + session_children_dict[root_id] = root_folder_node + session_node["children"] = list(session_children_dict.values()) + return session_node + + +def build_nested_folders( + file_module: Any, + file_node: TestNode, + created_files_folders_dict: Dict[str, TestNode], + session: pytest.Session, +) -> TestNode: + """Takes a file or folder and builds the nested folder structure for it. + + Keyword arguments: + file_module -- the created module for the file we are nesting. + file_node -- the file node that we are building the nested folders for. + created_files_folders_dict -- Dictionary of all the folders and files that have been created. + session -- the pytest session object. + """ + prev_folder_node = file_node + + # Begin the iterator_path one level above the current file. + iterator_path = file_module.path.parent + while iterator_path != session.path: + curr_folder_name = iterator_path.name + try: + curr_folder_node: TestNode = created_files_folders_dict[curr_folder_name] + except KeyError: + curr_folder_node: TestNode = create_folder_node( + curr_folder_name, iterator_path + ) + created_files_folders_dict[curr_folder_name] = curr_folder_node + if prev_folder_node not in curr_folder_node["children"]: + curr_folder_node["children"].append(prev_folder_node) + iterator_path = iterator_path.parent + prev_folder_node = curr_folder_node + return prev_folder_node + + +def create_test_node( + test_case: pytest.Item, +) -> TestItem: + """Creates a test node from a pytest test case. + + Keyword arguments: + test_case -- the pytest test case. + """ + test_case_loc: str = ( + str(test_case.location[1] + 1) if (test_case.location[1] is not None) else "" + ) + return { + "name": test_case.name, + "path": os.fspath(test_case.path), + "lineno": test_case_loc, + "type_": "test", + "id_": test_case.nodeid, + "runID": test_case.nodeid, + } + + +def create_session_node(session: pytest.Session) -> TestNode: + """Creates a session node from a pytest session. + + Keyword arguments: + session -- the pytest session. + """ + return { + "name": session.name, + "path": os.fspath(session.path), + "type_": "folder", + "children": [], + "id_": os.fspath(session.path), + } + + +def create_class_node(class_module: pytest.Class) -> TestNode: + """Creates a class node from a pytest class object. + + Keyword arguments: + class_module -- the pytest object representing a class module. + """ + return { + "name": class_module.name, + "path": os.fspath(class_module.path), + "type_": "class", + "children": [], + "id_": class_module.nodeid, + } + + +def create_file_node(file_module: Any) -> TestNode: + """Creates a file node from a pytest file module. + + Keyword arguments: + file_module -- the pytest file module. + """ + return { + "name": file_module.path.name, + "path": os.fspath(file_module.path), + "type_": "file", + "id_": os.fspath(file_module.path), + "children": [], + } + + +def create_folder_node(folderName: str, path_iterator: pathlib.Path) -> TestNode: + """Creates a folder node from a pytest folder name and its path. + + Keyword arguments: + folderName -- the name of the folder. + path_iterator -- the path of the folder. + """ + return { + "name": folderName, + "path": os.fspath(path_iterator), + "type_": "folder", + "id_": os.fspath(path_iterator), + "children": [], + } + + +class DiscoveryPayloadDict(TypedDict): + """A dictionary that is used to send a post request to the server.""" + + cwd: str + status: Literal["success", "error"] + tests: Optional[TestNode] + error: Optional[List[str]] + + +class ExecutionPayloadDict(Dict): + """ + A dictionary that is used to send a execution post request to the server. + """ + + cwd: str + status: Literal["success", "error"] + result: Union[testRunResultDict, None] + not_found: Union[List[str], None] # Currently unused need to check + error: Union[str, None] # Currently unused need to check + + +def execution_post( + cwd: str, + status: Literal["success", "error"], + tests: Union[testRunResultDict, None], +): + """ + Sends a post request to the server after the tests have been executed. + Keyword arguments: + cwd -- the current working directory. + session_node -- the status of running the tests + tests -- the tests that were run and their status. + """ + testPort = os.getenv("TEST_PORT", 45454) + testuuid = os.getenv("TEST_UUID") + payload: ExecutionPayloadDict = ExecutionPayloadDict( + cwd=cwd, status=status, result=tests, not_found=None, error=None + ) + if ERRORS: + payload["error"] = ERRORS + + addr = ("localhost", int(testPort)) + data = json.dumps(payload) + request = f"""Content-Length: {len(data)} +Content-Type: application/json +Request-uuid: {testuuid} + +{data}""" + test_output_file: Optional[str] = os.getenv("TEST_OUTPUT_FILE", None) + if test_output_file == "stdout": + print(request) + elif test_output_file: + pathlib.Path(test_output_file).write_text(request, encoding="utf-8") + else: + try: + with socket_manager.SocketManager(addr) as s: + if s.socket is not None: + s.socket.sendall(request.encode("utf-8")) + except Exception as e: + print(f"Plugin error connection error[vscode-pytest]: {e}") + print(f"[vscode-pytest] data: {request}") + + +def post_response(cwd: str, session_node: TestNode) -> None: + """Sends a post request to the server. + + Keyword arguments: + cwd -- the current working directory. + session_node -- the session node, which is the top of the testing tree. + errors -- a list of errors that occurred during test collection. + """ + payload: DiscoveryPayloadDict = { + "cwd": cwd, + "status": "success" if not ERRORS else "error", + "tests": session_node, + "error": [], + } + if ERRORS is not None: + payload["error"] = ERRORS + testPort: Union[str, int] = os.getenv("TEST_PORT", 45454) + testuuid: Union[str, None] = os.getenv("TEST_UUID") + addr = "localhost", int(testPort) + data = json.dumps(payload) + request = f"""Content-Length: {len(data)} +Content-Type: application/json +Request-uuid: {testuuid} + +{data}""" + test_output_file: Optional[str] = os.getenv("TEST_OUTPUT_FILE", None) + if test_output_file == "stdout": + print(request) + elif test_output_file: + pathlib.Path(test_output_file).write_text(request, encoding="utf-8") + else: + try: + with socket_manager.SocketManager(addr) as s: + if s.socket is not None: + s.socket.sendall(request.encode("utf-8")) + except Exception as e: + print(f"Plugin error connection error[vscode-pytest]: {e}") + print(f"[vscode-pytest] data: {request}") diff --git a/extensions/positron-python/requirements.in b/extensions/positron-python/requirements.in index c394c0feb0c..8b76e392917 100644 --- a/extensions/positron-python/requirements.in +++ b/extensions/positron-python/requirements.in @@ -5,3 +5,6 @@ # Unittest test adapter typing-extensions==4.5.0 + +# Fallback env creator for debian +microvenv diff --git a/extensions/positron-python/requirements.txt b/extensions/positron-python/requirements.txt index 1cdb049c430..07145e1832d 100644 --- a/extensions/positron-python/requirements.txt +++ b/extensions/positron-python/requirements.txt @@ -1,9 +1,13 @@ # -# This file is autogenerated by pip-compile with python 3.7 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.7 +# by the following command: # # pip-compile --generate-hashes requirements.in # +microvenv==2023.2.0 \ + --hash=sha256:5b46296d6a65992946da504bd9e724a5becf5c256091f2f9383e5b4e9f567f23 \ + --hash=sha256:a07e88a8fb5ee90219b86dd90095cb5646462d45d30285ea3b1a3c7cf33616d3 + # via -r requirements.in typing-extensions==4.5.0 \ --hash=sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb \ --hash=sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4 diff --git a/extensions/positron-python/src/client/activation/common/analysisOptions.ts b/extensions/positron-python/src/client/activation/common/analysisOptions.ts index 18de19384fb..75d0aabef9d 100644 --- a/extensions/positron-python/src/client/activation/common/analysisOptions.ts +++ b/extensions/positron-python/src/client/activation/common/analysisOptions.ts @@ -5,7 +5,7 @@ import { DocumentFilter, LanguageClientOptions, RevealOutputChannelOn } from 'vs import { IWorkspaceService } from '../../common/application/types'; import { PYTHON, PYTHON_LANGUAGE } from '../../common/constants'; -import { IOutputChannel, Resource } from '../../common/types'; +import { ILogOutputChannel, Resource } from '../../common/types'; import { debounceSync } from '../../common/utils/decorators'; import { IEnvironmentVariablesProvider } from '../../common/variables/types'; import { traceDecoratorError } from '../../logging'; @@ -14,7 +14,7 @@ import { ILanguageServerAnalysisOptions, ILanguageServerOutputChannel } from '.. export abstract class LanguageServerAnalysisOptionsBase implements ILanguageServerAnalysisOptions { protected readonly didChange = new EventEmitter(); - private readonly output: IOutputChannel; + private readonly output: ILogOutputChannel; protected constructor( lsOutputChannel: ILanguageServerOutputChannel, diff --git a/extensions/positron-python/src/client/activation/common/outputChannel.ts b/extensions/positron-python/src/client/activation/common/outputChannel.ts index 830bfbfdf55..60a99687793 100644 --- a/extensions/positron-python/src/client/activation/common/outputChannel.ts +++ b/extensions/positron-python/src/client/activation/common/outputChannel.ts @@ -6,13 +6,13 @@ import { inject, injectable } from 'inversify'; import { IApplicationShell, ICommandManager } from '../../common/application/types'; import '../../common/extensions'; -import { IDisposableRegistry, IOutputChannel } from '../../common/types'; +import { IDisposableRegistry, ILogOutputChannel } from '../../common/types'; import { OutputChannelNames } from '../../common/utils/localize'; import { ILanguageServerOutputChannel } from '../types'; @injectable() export class LanguageServerOutputChannel implements ILanguageServerOutputChannel { - public output: IOutputChannel | undefined; + public output: ILogOutputChannel | undefined; private registered = false; @@ -22,7 +22,7 @@ export class LanguageServerOutputChannel implements ILanguageServerOutputChannel @inject(IDisposableRegistry) private readonly disposable: IDisposableRegistry, ) {} - public get channel(): IOutputChannel { + public get channel(): ILogOutputChannel { if (!this.output) { this.output = this.appShell.createOutputChannel(OutputChannelNames.languageServer); this.disposable.push(this.output); diff --git a/extensions/positron-python/src/client/activation/node/analysisOptions.ts b/extensions/positron-python/src/client/activation/node/analysisOptions.ts index 80dbc53a59e..71295649c25 100644 --- a/extensions/positron-python/src/client/activation/node/analysisOptions.ts +++ b/extensions/positron-python/src/client/activation/node/analysisOptions.ts @@ -1,27 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { ConfigurationTarget, extensions, WorkspaceConfiguration } from 'vscode'; import { LanguageClientOptions } from 'vscode-languageclient'; -import * as semver from 'semver'; import { IWorkspaceService } from '../../common/application/types'; -import { PYLANCE_EXTENSION_ID } from '../../common/constants'; -import { IExperimentService } from '../../common/types'; import { LanguageServerAnalysisOptionsBase } from '../common/analysisOptions'; import { ILanguageServerOutputChannel } from '../types'; -import { traceWarn } from '../../logging'; - -const EDITOR_CONFIG_SECTION = 'editor'; -const FORMAT_ON_TYPE_CONFIG_SETTING = 'formatOnType'; export class NodeLanguageServerAnalysisOptions extends LanguageServerAnalysisOptionsBase { // eslint-disable-next-line @typescript-eslint/no-useless-constructor - constructor( - lsOutputChannel: ILanguageServerOutputChannel, - workspace: IWorkspaceService, - private readonly experimentService: IExperimentService, - ) { + constructor(lsOutputChannel: ILanguageServerOutputChannel, workspace: IWorkspaceService) { super(lsOutputChannel, workspace); } @@ -34,72 +22,6 @@ export class NodeLanguageServerAnalysisOptions extends LanguageServerAnalysisOpt return ({ experimentationSupport: true, trustedWorkspaceSupport: true, - autoIndentSupport: await this.isAutoIndentEnabled(), } as unknown) as LanguageClientOptions; } - - private async isAutoIndentEnabled() { - let editorConfig = this.getPythonSpecificEditorSection(); - - // Only explicitly enable formatOnType for those who are in the experiment - // but have not explicitly given a value for the setting - if (!NodeLanguageServerAnalysisOptions.isConfigSettingSetByUser(editorConfig, FORMAT_ON_TYPE_CONFIG_SETTING)) { - const inExperiment = await this.isInAutoIndentExperiment(); - if (inExperiment) { - await NodeLanguageServerAnalysisOptions.setPythonSpecificFormatOnType(editorConfig, true); - - // Refresh our view of the config settings. - editorConfig = this.getPythonSpecificEditorSection(); - } - } - - const formatOnTypeEffectiveValue = editorConfig.get(FORMAT_ON_TYPE_CONFIG_SETTING); - - return formatOnTypeEffectiveValue; - } - - private static isConfigSettingSetByUser(configuration: WorkspaceConfiguration, setting: string): boolean { - const inspect = configuration.inspect(setting); - if (inspect === undefined) { - return false; - } - - return ( - inspect.globalValue !== undefined || - inspect.workspaceValue !== undefined || - inspect.workspaceFolderValue !== undefined || - inspect.globalLanguageValue !== undefined || - inspect.workspaceLanguageValue !== undefined || - inspect.workspaceFolderLanguageValue !== undefined - ); - } - - private async isInAutoIndentExperiment(): Promise { - if (await this.experimentService.inExperiment('pylanceAutoIndent')) { - return true; - } - - const pylanceVersion = extensions.getExtension(PYLANCE_EXTENSION_ID)?.packageJSON.version as string; - return pylanceVersion !== undefined && semver.prerelease(pylanceVersion)?.includes('dev') === true; - } - - private getPythonSpecificEditorSection() { - return this.workspace.getConfiguration(EDITOR_CONFIG_SECTION, undefined, /* languageSpecific */ true); - } - - private static async setPythonSpecificFormatOnType( - editorConfig: WorkspaceConfiguration, - value: boolean | undefined, - ) { - try { - await editorConfig.update( - FORMAT_ON_TYPE_CONFIG_SETTING, - value, - ConfigurationTarget.Global, - /* overrideInLanguage */ true, - ); - } catch (ex) { - traceWarn(`Failed to set formatOnType to ${value}`); - } - } } diff --git a/extensions/positron-python/src/client/activation/types.ts b/extensions/positron-python/src/client/activation/types.ts index 873d608f0bd..2a177bb570b 100644 --- a/extensions/positron-python/src/client/activation/types.ts +++ b/extensions/positron-python/src/client/activation/types.ts @@ -5,7 +5,7 @@ import { Event } from 'vscode'; import { LanguageClient, LanguageClientOptions } from 'vscode-languageclient/node'; -import type { IDisposable, IOutputChannel, Resource } from '../common/types'; +import type { IDisposable, ILogOutputChannel, Resource } from '../common/types'; import { PythonEnvironment } from '../pythonEnvironments/info'; export const IExtensionActivationManager = Symbol('IExtensionActivationManager'); @@ -110,10 +110,10 @@ export interface ILanguageServerOutputChannel { /** * Creates output channel if necessary and returns it * - * @type {IOutputChannel} + * @type {ILogOutputChannel} * @memberof ILanguageServerOutputChannel */ - readonly channel: IOutputChannel; + readonly channel: ILogOutputChannel; } export const IExtensionSingleActivationService = Symbol('IExtensionSingleActivationService'); diff --git a/extensions/positron-python/src/client/application/diagnostics/applicationDiagnostics.ts b/extensions/positron-python/src/client/application/diagnostics/applicationDiagnostics.ts index ba31021fc34..493c6cfece5 100644 --- a/extensions/positron-python/src/client/application/diagnostics/applicationDiagnostics.ts +++ b/extensions/positron-python/src/client/application/diagnostics/applicationDiagnostics.ts @@ -7,7 +7,7 @@ import { IWorkspaceService } from '../../common/application/types'; import { isTestExecution } from '../../common/constants'; import { Resource } from '../../common/types'; import { IServiceContainer } from '../../ioc/types'; -import { traceInfo, traceLog } from '../../logging'; +import { traceLog, traceVerbose } from '../../logging'; import { IApplicationDiagnostics } from '../types'; import { IDiagnostic, IDiagnosticsService, ISourceMapSupportService } from './types'; @@ -21,7 +21,7 @@ function log(diagnostics: IDiagnostic[]): void { break; } default: { - traceInfo(message); + traceVerbose(message); } } }); diff --git a/extensions/positron-python/src/client/common/application/applicationShell.ts b/extensions/positron-python/src/client/common/application/applicationShell.ts index c1a5de51b7f..45466247201 100644 --- a/extensions/positron-python/src/client/common/application/applicationShell.ts +++ b/extensions/positron-python/src/client/common/application/applicationShell.ts @@ -14,10 +14,10 @@ import { InputBoxOptions, languages, LanguageStatusItem, + LogOutputChannel, MessageItem, MessageOptions, OpenDialogOptions, - OutputChannel, Progress, ProgressOptions, QuickPick, @@ -166,8 +166,8 @@ export class ApplicationShell implements IApplicationShell { public createTreeView(viewId: string, options: TreeViewOptions): TreeView { return window.createTreeView(viewId, options); } - public createOutputChannel(name: string): OutputChannel { - return window.createOutputChannel(name); + public createOutputChannel(name: string): LogOutputChannel { + return window.createOutputChannel(name, { log: true }); } public createLanguageStatusItem(id: string, selector: DocumentSelector): LanguageStatusItem { return languages.createLanguageStatusItem(id, selector); diff --git a/extensions/positron-python/src/client/common/application/commands.ts b/extensions/positron-python/src/client/common/application/commands.ts index 277baffd19a..2a440444010 100644 --- a/extensions/positron-python/src/client/common/application/commands.ts +++ b/extensions/positron-python/src/client/common/application/commands.ts @@ -17,6 +17,7 @@ export type CommandsWithoutArgs = keyof ICommandNameWithoutArgumentTypeMapping; */ interface ICommandNameWithoutArgumentTypeMapping { [Commands.InstallPythonOnMac]: []; + [Commands.InstallJupyter]: []; [Commands.InstallPythonOnLinux]: []; [Commands.InstallPython]: []; [Commands.ClearWorkspaceInterpreter]: []; diff --git a/extensions/positron-python/src/client/common/application/contextKeys.ts b/extensions/positron-python/src/client/common/application/contextKeys.ts index 2f791ae6684..d6249f05eae 100644 --- a/extensions/positron-python/src/client/common/application/contextKeys.ts +++ b/extensions/positron-python/src/client/common/application/contextKeys.ts @@ -5,4 +5,5 @@ export enum ExtensionContextKey { showInstallPythonTile = 'showInstallPythonTile', HasFailedTests = 'hasFailedTests', RefreshingTests = 'refreshingTests', + IsJupyterInstalled = 'isJupyterInstalled', } diff --git a/extensions/positron-python/src/client/common/application/types.ts b/extensions/positron-python/src/client/common/application/types.ts index 1b054eda687..77d7b5af327 100644 --- a/extensions/positron-python/src/client/common/application/types.ts +++ b/extensions/positron-python/src/client/common/application/types.ts @@ -25,10 +25,10 @@ import { InputBox, InputBoxOptions, LanguageStatusItem, + LogOutputChannel, MessageItem, MessageOptions, OpenDialogOptions, - OutputChannel, Progress, ProgressOptions, QuickPick, @@ -429,7 +429,7 @@ export interface IApplicationShell { * * @param name Human-readable string which will be used to represent the channel in the UI. */ - createOutputChannel(name: string): OutputChannel; + createOutputChannel(name: string): LogOutputChannel; createLanguageStatusItem(id: string, selector: DocumentSelector): LanguageStatusItem; } diff --git a/extensions/positron-python/src/client/common/constants.ts b/extensions/positron-python/src/client/common/constants.ts index da44a7bfe67..b285667aaa6 100644 --- a/extensions/positron-python/src/client/common/constants.ts +++ b/extensions/positron-python/src/client/common/constants.ts @@ -37,6 +37,7 @@ export namespace Commands { export const CreateNewFile = 'python.createNewFile'; export const ClearWorkspaceInterpreter = 'python.clearWorkspaceInterpreter'; export const Create_Environment = 'python.createEnvironment'; + export const Create_Environment_Button = 'python.createEnvironment-button'; export const Create_Terminal = 'python.createTerminal'; export const Debug_In_Terminal = 'python.debugInTerminal'; export const Enable_Linter = 'python.enableLinting'; @@ -46,6 +47,7 @@ export namespace Commands { export const Exec_Selection_In_Django_Shell = 'python.execSelectionInDjangoShell'; export const Exec_Selection_In_Terminal = 'python.execSelectionInTerminal'; export const GetSelectedInterpreterPath = 'python.interpreterPath'; + export const InstallJupyter = 'python.installJupyter'; export const InstallPython = 'python.installPython'; export const InstallPythonOnLinux = 'python.installPythonOnLinux'; export const InstallPythonOnMac = 'python.installPythonOnMac'; @@ -93,8 +95,6 @@ export namespace ThemeIcons { export const DEFAULT_INTERPRETER_SETTING = 'python'; -export const STANDARD_OUTPUT_CHANNEL = 'STANDARD_OUTPUT_CHANNEL'; - export const isCI = process.env.TRAVIS === 'true' || process.env.TF_BUILD !== undefined; export function isTestExecution(): boolean { diff --git a/extensions/positron-python/src/client/common/experiments/helpers.ts b/extensions/positron-python/src/client/common/experiments/helpers.ts index 4aed04da3fd..04da948fd15 100644 --- a/extensions/positron-python/src/client/common/experiments/helpers.ts +++ b/extensions/positron-python/src/client/common/experiments/helpers.ts @@ -3,17 +3,10 @@ 'use strict'; -import { workspace } from 'vscode'; -import { isTestExecution } from '../constants'; import { IExperimentService } from '../types'; import { TerminalEnvVarActivation } from './groups'; export function inTerminalEnvVarExperiment(experimentService: IExperimentService): boolean { - if (workspace.workspaceFile && !isTestExecution()) { - // Don't run experiment in multi-root workspaces for now, requires work on VSCode: - // https://github.com/microsoft/vscode/issues/171173 - return false; - } if (!experimentService.inExperimentSync(TerminalEnvVarActivation.experiment)) { return false; } diff --git a/extensions/positron-python/src/client/common/installer/moduleInstaller.ts b/extensions/positron-python/src/client/common/installer/moduleInstaller.ts index 4049edb8ec0..62160b7e25c 100644 --- a/extensions/positron-python/src/client/common/installer/moduleInstaller.ts +++ b/extensions/positron-python/src/client/common/installer/moduleInstaller.ts @@ -12,12 +12,11 @@ import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { IApplicationShell } from '../application/types'; import { wrapCancellationTokens } from '../cancellation'; -import { STANDARD_OUTPUT_CHANNEL } from '../constants'; import { IFileSystem } from '../platform/types'; import * as internalPython from '../process/internal/python'; import { IProcessServiceFactory } from '../process/types'; import { ITerminalServiceFactory, TerminalCreationOptions } from '../terminal/types'; -import { ExecutionInfo, IConfigurationService, IOutputChannel, Product } from '../types'; +import { ExecutionInfo, IConfigurationService, ILogOutputChannel, Product } from '../types'; import { isResource } from '../utils/misc'; import { ProductNames } from './productNames'; import { IModuleInstaller, InstallOptions, InterpreterUri, ModuleInstallFlags } from './types'; @@ -152,7 +151,7 @@ export abstract class ModuleInstaller implements IModuleInstaller { const options = { name: 'VS Code Python', }; - const outputChannel = this.serviceContainer.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); + const outputChannel = this.serviceContainer.get(ILogOutputChannel); const command = `"${execPath.replace(/\\/g, '/')}" ${args.join(' ')}`; traceLog(`[Elevated] ${command}`); diff --git a/extensions/positron-python/src/client/common/platform/fs-paths.ts b/extensions/positron-python/src/client/common/platform/fs-paths.ts index 18a1fea363b..2d46fca9852 100644 --- a/extensions/positron-python/src/client/common/platform/fs-paths.ts +++ b/extensions/positron-python/src/client/common/platform/fs-paths.ts @@ -145,7 +145,11 @@ export class FileSystemPathUtils implements IFileSystemPathUtils { } export function normCasePath(filePath: string): string { - return getOSType() === OSType.Windows ? nodepath.normalize(filePath).toUpperCase() : nodepath.normalize(filePath); + return normCase(nodepath.normalize(filePath)); +} + +export function normCase(s: string): string { + return getOSType() === OSType.Windows ? s.toUpperCase() : s; } /** diff --git a/extensions/positron-python/src/client/common/process/logger.ts b/extensions/positron-python/src/client/common/process/logger.ts index 5c8f04cbec3..502aaf44cc4 100644 --- a/extensions/positron-python/src/client/common/process/logger.ts +++ b/extensions/positron-python/src/client/common/process/logger.ts @@ -28,7 +28,10 @@ export class ProcessLogger implements IProcessLogger { : fileOrCommand; const info = [`> ${this.getDisplayCommands(command)}`]; if (options?.cwd) { - info.push(`cwd: ${this.getDisplayCommands(options.cwd)}`); + // --- Start Positron --- + const cwd: string = options?.cwd?.toString(); + // --- End Positron --- + info.push(`cwd: ${this.getDisplayCommands(cwd)}`); } if (typeof options?.shell === 'string') { info.push(`shell: ${identifyShellFromShellPath(options?.shell)}`); diff --git a/extensions/positron-python/src/client/common/process/pythonEnvironment.ts b/extensions/positron-python/src/client/common/process/pythonEnvironment.ts index f4c6f895412..9566f373aa9 100644 --- a/extensions/positron-python/src/client/common/process/pythonEnvironment.ts +++ b/extensions/positron-python/src/client/common/process/pythonEnvironment.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import * as path from 'path'; -import { traceError, traceInfo } from '../../logging'; +import { traceError, traceVerbose } from '../../logging'; import { Conda, CondaEnvironmentInfo } from '../../pythonEnvironments/common/environmentManagers/conda'; import { buildPythonExecInfo, PythonExecInfo } from '../../pythonEnvironments/exec'; import { InterpreterInformation } from '../../pythonEnvironments/info'; @@ -71,7 +71,7 @@ class PythonEnvironment implements IPythonEnvironment { try { data = await this.deps.exec(info.command, info.args); } catch (ex) { - traceInfo(`Error when getting version of module ${moduleName}`, ex); + traceVerbose(`Error when getting version of module ${moduleName}`, ex); return undefined; } return parse(data.stdout); @@ -84,7 +84,7 @@ class PythonEnvironment implements IPythonEnvironment { try { await this.deps.exec(info.command, info.args); } catch (ex) { - traceInfo(`Error when checking if module is installed ${moduleName}`, ex); + traceVerbose(`Error when checking if module is installed ${moduleName}`, ex); return false; } return true; @@ -93,7 +93,7 @@ class PythonEnvironment implements IPythonEnvironment { private async getInterpreterInformationImpl(): Promise { try { const python = this.getExecutionInfo(); - return await getInterpreterInfo(python, this.deps.shellExec, { info: traceInfo, error: traceError }); + return await getInterpreterInfo(python, this.deps.shellExec, { verbose: traceVerbose, error: traceError }); } catch (ex) { traceError(`Failed to get interpreter information for '${this.pythonPath}'`, ex); } diff --git a/extensions/positron-python/src/client/common/process/rawProcessApis.ts b/extensions/positron-python/src/client/common/process/rawProcessApis.ts index 59b5fe69c9c..025e5b60722 100644 --- a/extensions/positron-python/src/client/common/process/rawProcessApis.ts +++ b/extensions/positron-python/src/client/common/process/rawProcessApis.ts @@ -121,7 +121,10 @@ export function plainExec( } const stdoutBuffers: Buffer[] = []; - on(proc.stdout, 'data', (data: Buffer) => stdoutBuffers.push(data)); + on(proc.stdout, 'data', (data: Buffer) => { + stdoutBuffers.push(data); + options.outputChannel?.append(data.toString()); + }); const stderrBuffers: Buffer[] = []; on(proc.stderr, 'data', (data: Buffer) => { if (options.mergeStdOutErr) { @@ -130,6 +133,7 @@ export function plainExec( } else { stderrBuffers.push(data); } + options.outputChannel?.append(data.toString()); }); proc.once('close', () => { @@ -188,7 +192,7 @@ export function execObservable( let procExited = false; const disposable: IDisposable = { dispose() { - if (proc && !proc.killed && !procExited) { + if (proc && proc.pid && !proc.killed && !procExited) { killPid(proc.pid); } if (proc) { diff --git a/extensions/positron-python/src/client/common/process/types.ts b/extensions/positron-python/src/client/common/process/types.ts index bcab76e66b0..8298957285e 100644 --- a/extensions/positron-python/src/client/common/process/types.ts +++ b/extensions/positron-python/src/client/common/process/types.ts @@ -3,7 +3,7 @@ import { ChildProcess, ExecOptions, SpawnOptions as ChildProcessSpawnOptions } from 'child_process'; import { Observable } from 'rxjs/Observable'; -import { CancellationToken, Uri } from 'vscode'; +import { CancellationToken, OutputChannel, Uri } from 'vscode'; import { PythonExecInfo } from '../../pythonEnvironments/exec'; import { InterpreterInformation, PythonEnvironment } from '../../pythonEnvironments/info'; import { ExecutionInfo, IDisposable } from '../types'; @@ -24,6 +24,7 @@ export type SpawnOptions = ChildProcessSpawnOptions & { mergeStdOutErr?: boolean; throwOnStdErr?: boolean; extraVariables?: NodeJS.ProcessEnv; + outputChannel?: OutputChannel; }; export type ShellOptions = ExecOptions & { throwOnStdErr?: boolean }; diff --git a/extensions/positron-python/src/client/common/serviceRegistry.ts b/extensions/positron-python/src/client/common/serviceRegistry.ts index 93f069f1828..5b527499460 100644 --- a/extensions/positron-python/src/client/common/serviceRegistry.ts +++ b/extensions/positron-python/src/client/common/serviceRegistry.ts @@ -65,6 +65,7 @@ import { IProcessLogger } from './process/types'; import { TerminalActivator } from './terminal/activator'; import { PowershellTerminalActivationFailedHandler } from './terminal/activator/powershellFailedHandler'; import { Bash } from './terminal/environmentActivationProviders/bash'; +import { Nushell } from './terminal/environmentActivationProviders/nushell'; import { CommandPromptAndPowerShell } from './terminal/environmentActivationProviders/commandPrompt'; import { CondaActivationCommandProvider } from './terminal/environmentActivationProviders/condaActivationProvider'; import { PipEnvActivationCommandProvider } from './terminal/environmentActivationProviders/pipEnvActivationProvider'; @@ -89,6 +90,7 @@ import { IMultiStepInputFactory, MultiStepInputFactory } from './utils/multiStep import { Random } from './utils/random'; import { ContextKeyManager } from './application/contextKeyManager'; import { CreatePythonFileCommandHandler } from './application/commands/createPythonFile'; +import { RequireJupyterPrompt } from '../jupyter/requireJupyterPrompt'; export function registerTypes(serviceManager: IServiceManager): void { serviceManager.addSingletonInstance(IsWindows, IS_WINDOWS); @@ -109,6 +111,10 @@ export function registerTypes(serviceManager: IServiceManager): void { IJupyterExtensionDependencyManager, JupyterExtensionDependencyManager, ); + serviceManager.addSingleton( + IExtensionSingleActivationService, + RequireJupyterPrompt, + ); serviceManager.addSingleton( IExtensionSingleActivationService, CreatePythonFileCommandHandler, @@ -143,6 +149,11 @@ export function registerTypes(serviceManager: IServiceManager): void { CommandPromptAndPowerShell, TerminalActivationProviders.commandPromptAndPowerShell, ); + serviceManager.addSingleton( + ITerminalActivationCommandProvider, + Nushell, + TerminalActivationProviders.nushell, + ); serviceManager.addSingleton( ITerminalActivationCommandProvider, PyEnvActivationCommandProvider, diff --git a/extensions/positron-python/src/client/common/terminal/activator/index.ts b/extensions/positron-python/src/client/common/terminal/activator/index.ts index 5bc76c0cb0f..1c2cf404158 100644 --- a/extensions/positron-python/src/client/common/terminal/activator/index.ts +++ b/extensions/positron-python/src/client/common/terminal/activator/index.ts @@ -5,9 +5,10 @@ import { inject, injectable, multiInject } from 'inversify'; import { Terminal } from 'vscode'; -import { IConfigurationService } from '../../types'; +import { IConfigurationService, IExperimentService } from '../../types'; import { ITerminalActivationHandler, ITerminalActivator, ITerminalHelper, TerminalActivationOptions } from '../types'; import { BaseTerminalActivator } from './base'; +import { inTerminalEnvVarExperiment } from '../../experiments/helpers'; @injectable() export class TerminalActivator implements ITerminalActivator { @@ -17,6 +18,7 @@ export class TerminalActivator implements ITerminalActivator { @inject(ITerminalHelper) readonly helper: ITerminalHelper, @multiInject(ITerminalActivationHandler) private readonly handlers: ITerminalActivationHandler[], @inject(IConfigurationService) private readonly configurationService: IConfigurationService, + @inject(IExperimentService) private readonly experimentService: IExperimentService, ) { this.initialize(); } @@ -37,7 +39,8 @@ export class TerminalActivator implements ITerminalActivator { options?: TerminalActivationOptions, ): Promise { const settings = this.configurationService.getSettings(options?.resource); - const activateEnvironment = settings.terminal.activateEnvironment; + const activateEnvironment = + settings.terminal.activateEnvironment && !inTerminalEnvVarExperiment(this.experimentService); if (!activateEnvironment || options?.hideFromUser) { return false; } diff --git a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/baseActivationProvider.ts b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/baseActivationProvider.ts index ca87b172f0a..abc2ff89df6 100644 --- a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/baseActivationProvider.ts +++ b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/baseActivationProvider.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. @@ -48,6 +49,7 @@ abstract class BaseActivationCommandProvider implements ITerminalActivationComma constructor(@inject(IServiceContainer) protected readonly serviceContainer: IServiceContainer) {} public abstract isShellSupported(targetShell: TerminalShellType): boolean; + public async getActivationCommands( resource: Uri | undefined, targetShell: TerminalShellType, @@ -60,13 +62,14 @@ abstract class BaseActivationCommandProvider implements ITerminalActivationComma } return this.getActivationCommandsForInterpreter(interpreter.path, targetShell); } + public abstract getActivationCommandsForInterpreter( pythonPath: string, targetShell: TerminalShellType, ): Promise; } -export type ActivationScripts = Record; +export type ActivationScripts = Partial>; export abstract class VenvBaseActivationCommandProvider extends BaseActivationCommandProvider { public isShellSupported(targetShell: TerminalShellType): boolean { diff --git a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/bash.ts b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/bash.ts index 82720103757..00c4d3da114 100644 --- a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/bash.ts +++ b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/bash.ts @@ -7,7 +7,7 @@ import { TerminalShellType } from '../types'; import { ActivationScripts, VenvBaseActivationCommandProvider } from './baseActivationProvider'; // For a given shell the scripts are in order of precedence. -const SCRIPTS: ActivationScripts = ({ +const SCRIPTS: ActivationScripts = { // Group 1 [TerminalShellType.wsl]: ['activate.sh', 'activate'], [TerminalShellType.ksh]: ['activate.sh', 'activate'], @@ -19,13 +19,12 @@ const SCRIPTS: ActivationScripts = ({ [TerminalShellType.cshell]: ['activate.csh'], // Group 3 [TerminalShellType.fish]: ['activate.fish'], -} as unknown) as ActivationScripts; +}; export function getAllScripts(): string[] { const scripts: string[] = []; - for (const key of Object.keys(SCRIPTS)) { - const shell = key as TerminalShellType; - for (const name of SCRIPTS[shell]) { + for (const names of Object.values(SCRIPTS)) { + for (const name of names) { if (!scripts.includes(name)) { scripts.push(name); } @@ -44,7 +43,7 @@ export class Bash extends VenvBaseActivationCommandProvider { ): Promise { const scriptFile = await this.findScriptFile(pythonPath, targetShell); if (!scriptFile) { - return; + return undefined; } return [`source ${scriptFile.fileToCommandArgumentForPythonExt()}`]; } diff --git a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/commandPrompt.ts b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/commandPrompt.ts index 25ab46ca1fb..6d40e2c390a 100644 --- a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/commandPrompt.ts +++ b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/commandPrompt.ts @@ -9,19 +9,18 @@ import { TerminalShellType } from '../types'; import { ActivationScripts, VenvBaseActivationCommandProvider } from './baseActivationProvider'; // For a given shell the scripts are in order of precedence. -const SCRIPTS: ActivationScripts = ({ +const SCRIPTS: ActivationScripts = { // Group 1 [TerminalShellType.commandPrompt]: ['activate.bat', 'Activate.ps1'], // Group 2 [TerminalShellType.powershell]: ['Activate.ps1', 'activate.bat'], [TerminalShellType.powershellCore]: ['Activate.ps1', 'activate.bat'], -} as unknown) as ActivationScripts; +}; export function getAllScripts(pathJoin: (...p: string[]) => string): string[] { const scripts: string[] = []; - for (const key of Object.keys(SCRIPTS)) { - const shell = key as TerminalShellType; - for (const name of SCRIPTS[shell]) { + for (const names of Object.values(SCRIPTS)) { + for (const name of names) { if (!scripts.includes(name)) { scripts.push( name, @@ -38,13 +37,14 @@ export function getAllScripts(pathJoin: (...p: string[]) => string): string[] { @injectable() export class CommandPromptAndPowerShell extends VenvBaseActivationCommandProvider { protected readonly scripts: ActivationScripts; + constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { super(serviceContainer); - this.scripts = ({} as unknown) as ActivationScripts; - for (const key of Object.keys(SCRIPTS)) { + this.scripts = {}; + for (const [key, names] of Object.entries(SCRIPTS)) { const shell = key as TerminalShellType; const scripts: string[] = []; - for (const name of SCRIPTS[shell]) { + for (const name of names) { scripts.push( name, // We also add scripts in subdirs. @@ -62,21 +62,23 @@ export class CommandPromptAndPowerShell extends VenvBaseActivationCommandProvide ): Promise { const scriptFile = await this.findScriptFile(pythonPath, targetShell); if (!scriptFile) { - return; + return undefined; } if (targetShell === TerminalShellType.commandPrompt && scriptFile.endsWith('activate.bat')) { return [scriptFile.fileToCommandArgumentForPythonExt()]; - } else if ( + } + if ( (targetShell === TerminalShellType.powershell || targetShell === TerminalShellType.powershellCore) && scriptFile.endsWith('Activate.ps1') ) { return [`& ${scriptFile.fileToCommandArgumentForPythonExt()}`]; - } else if (targetShell === TerminalShellType.commandPrompt && scriptFile.endsWith('Activate.ps1')) { + } + if (targetShell === TerminalShellType.commandPrompt && scriptFile.endsWith('Activate.ps1')) { // lets not try to run the powershell file from command prompt (user may not have powershell) return []; - } else { - return; } + + return undefined; } } diff --git a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/nushell.ts b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/nushell.ts new file mode 100644 index 00000000000..333fd516777 --- /dev/null +++ b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/nushell.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { injectable } from 'inversify'; +import '../../extensions'; +import { TerminalShellType } from '../types'; +import { ActivationScripts, VenvBaseActivationCommandProvider } from './baseActivationProvider'; + +// For a given shell the scripts are in order of precedence. +const SCRIPTS: ActivationScripts = { + [TerminalShellType.nushell]: ['activate.nu'], +}; + +export function getAllScripts(): string[] { + const scripts: string[] = []; + for (const names of Object.values(SCRIPTS)) { + for (const name of names) { + if (!scripts.includes(name)) { + scripts.push(name); + } + } + } + return scripts; +} + +@injectable() +export class Nushell extends VenvBaseActivationCommandProvider { + protected readonly scripts = SCRIPTS; + + public async getActivationCommandsForInterpreter( + pythonPath: string, + targetShell: TerminalShellType, + ): Promise { + const scriptFile = await this.findScriptFile(pythonPath, targetShell); + if (!scriptFile) { + return undefined; + } + return [`overlay use ${scriptFile.fileToCommandArgumentForPythonExt()}`]; + } +} diff --git a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/pyenvActivationProvider.ts b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/pyenvActivationProvider.ts index 91347f35ae9..6b5ced04867 100644 --- a/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/pyenvActivationProvider.ts +++ b/extensions/positron-python/src/client/common/terminal/environmentActivationProviders/pyenvActivationProvider.ts @@ -14,6 +14,7 @@ import { ITerminalActivationCommandProvider, TerminalShellType } from '../types' export class PyEnvActivationCommandProvider implements ITerminalActivationCommandProvider { constructor(@inject(IServiceContainer) private readonly serviceContainer: IServiceContainer) {} + // eslint-disable-next-line class-methods-use-this public isShellSupported(_targetShell: TerminalShellType): boolean { return true; } @@ -23,7 +24,7 @@ export class PyEnvActivationCommandProvider implements ITerminalActivationComman .get(IInterpreterService) .getActiveInterpreter(resource); if (!interpreter || interpreter.envType !== EnvironmentType.Pyenv || !interpreter.envName) { - return; + return undefined; } return [`pyenv shell ${interpreter.envName.toCommandArgumentForPythonExt()}`]; @@ -37,7 +38,7 @@ export class PyEnvActivationCommandProvider implements ITerminalActivationComman .get(IInterpreterService) .getInterpreterDetails(pythonPath); if (!interpreter || interpreter.envType !== EnvironmentType.Pyenv || !interpreter.envName) { - return; + return undefined; } return [`pyenv shell ${interpreter.envName.toCommandArgumentForPythonExt()}`]; diff --git a/extensions/positron-python/src/client/common/terminal/helper.ts b/extensions/positron-python/src/client/common/terminal/helper.ts index 304c98b4cd8..f1a89df1078 100644 --- a/extensions/positron-python/src/client/common/terminal/helper.ts +++ b/extensions/positron-python/src/client/common/terminal/helper.ts @@ -42,6 +42,9 @@ export class TerminalHelper implements ITerminalHelper { @named(TerminalActivationProviders.commandPromptAndPowerShell) private readonly commandPromptAndPowerShell: ITerminalActivationCommandProvider, @inject(ITerminalActivationCommandProvider) + @named(TerminalActivationProviders.nushell) + private readonly nushell: ITerminalActivationCommandProvider, + @inject(ITerminalActivationCommandProvider) @named(TerminalActivationProviders.pyenv) private readonly pyenv: ITerminalActivationCommandProvider, @inject(ITerminalActivationCommandProvider) @@ -72,7 +75,7 @@ export class TerminalHelper implements ITerminalHelper { resource?: Uri, interpreter?: PythonEnvironment, ): Promise { - const providers = [this.pipenv, this.pyenv, this.bashCShellFish, this.commandPromptAndPowerShell]; + const providers = [this.pipenv, this.pyenv, this.bashCShellFish, this.commandPromptAndPowerShell, this.nushell]; const promise = this.getActivationCommands(resource || undefined, interpreter, terminalShellType, providers); this.sendTelemetry( terminalShellType, @@ -90,7 +93,7 @@ export class TerminalHelper implements ITerminalHelper { if (this.platform.osType === OSType.Unknown) { return; } - const providers = [this.bashCShellFish, this.commandPromptAndPowerShell]; + const providers = [this.bashCShellFish, this.commandPromptAndPowerShell, this.nushell]; const promise = this.getActivationCommands(resource, interpreter, shell, providers); this.sendTelemetry( shell, diff --git a/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts b/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts index 74d1fa5c6a2..b3793a07979 100644 --- a/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts +++ b/extensions/positron-python/src/client/common/terminal/shellDetectors/baseShellDetector.ts @@ -30,6 +30,7 @@ const IS_POWERSHELL_CORE = /(pwsh$)/i; const IS_FISH = /(fish$)/i; const IS_CSHELL = /(csh$)/i; const IS_TCSHELL = /(tcsh$)/i; +const IS_NUSHELL = /(nu$)/i; const IS_XONSH = /(xonsh$)/i; const detectableShells = new Map(); @@ -43,6 +44,7 @@ detectableShells.set(TerminalShellType.commandPrompt, IS_COMMAND); detectableShells.set(TerminalShellType.fish, IS_FISH); detectableShells.set(TerminalShellType.tcshell, IS_TCSHELL); detectableShells.set(TerminalShellType.cshell, IS_CSHELL); +detectableShells.set(TerminalShellType.nushell, IS_NUSHELL); detectableShells.set(TerminalShellType.powershellCore, IS_POWERSHELL_CORE); detectableShells.set(TerminalShellType.xonsh, IS_XONSH); diff --git a/extensions/positron-python/src/client/common/terminal/types.ts b/extensions/positron-python/src/client/common/terminal/types.ts index e02fa1c03fd..880bf0dd72f 100644 --- a/extensions/positron-python/src/client/common/terminal/types.ts +++ b/extensions/positron-python/src/client/common/terminal/types.ts @@ -11,6 +11,7 @@ import { IDisposable, Resource } from '../types'; export enum TerminalActivationProviders { bashCShellFish = 'bashCShellFish', commandPromptAndPowerShell = 'commandPromptAndPowerShell', + nushell = 'nushell', pyenv = 'pyenv', conda = 'conda', pipenv = 'pipenv', @@ -26,6 +27,7 @@ export enum TerminalShellType { fish = 'fish', cshell = 'cshell', tcshell = 'tshell', + nushell = 'nushell', wsl = 'wsl', xonsh = 'xonsh', other = 'other', diff --git a/extensions/positron-python/src/client/common/types.ts b/extensions/positron-python/src/client/common/types.ts index ca39496061b..d59f1fbd727 100644 --- a/extensions/positron-python/src/client/common/types.ts +++ b/extensions/positron-python/src/client/common/types.ts @@ -15,9 +15,10 @@ import { Extension, ExtensionContext, Memento, - OutputChannel, + LogOutputChannel, Uri, WorkspaceEdit, + OutputChannel, } from 'vscode'; import { LanguageServerType } from '../activation/types'; import type { InstallOptions, InterpreterUri, ModuleInstallFlags } from './installer/types'; @@ -29,8 +30,10 @@ export interface IDisposable { dispose(): void | undefined | Promise; } -export const IOutputChannel = Symbol('IOutputChannel'); -export interface IOutputChannel extends OutputChannel {} +export const ILogOutputChannel = Symbol('ILogOutputChannel'); +export interface ILogOutputChannel extends LogOutputChannel {} +export const ITestOutputChannel = Symbol('ITestOutputChannel'); +export interface ITestOutputChannel extends OutputChannel {} export const IDocumentSymbolProvider = Symbol('IDocumentSymbolProvider'); export interface IDocumentSymbolProvider extends DocumentSymbolProvider {} export const IsWindows = Symbol('IS_WINDOWS'); diff --git a/extensions/positron-python/src/client/common/utils/localize.ts b/extensions/positron-python/src/client/common/utils/localize.ts index 509287d8753..f32c4fec0ac 100644 --- a/extensions/positron-python/src/client/common/utils/localize.ts +++ b/extensions/positron-python/src/client/common/utils/localize.ts @@ -188,6 +188,9 @@ export namespace LanguageService { ); } export namespace Interpreters { + export const requireJupyter = l10n.t( + 'Running in Interactive window requires Jupyter Extension. Would you like to install it? [Learn more](https://aka.ms/pythonJupyterSupport).', + ); export const installingPython = l10n.t('Installing Python into Environment...'); export const discovering = l10n.t('Discovering Python Interpreters'); export const refreshing = l10n.t('Refreshing Python Interpreters'); @@ -195,6 +198,7 @@ export namespace Interpreters { 'We noticed you\'re using a conda environment. If you are experiencing issues with this environment in the integrated terminal, we recommend that you let the Python extension change "terminal.integrated.inheritEnv" to false in your user settings. [Learn more](https://aka.ms/AA66i8f).', ); export const activatingTerminals = l10n.t('Reactivating terminals...'); + export const activateTerminalDescription = l10n.t('Activated environment for'); export const activatedCondaEnvLaunch = l10n.t( 'We noticed VS Code was launched from an activated conda environment, would you like to select it?', ); @@ -218,6 +222,9 @@ export namespace Interpreters { } export namespace InterpreterQuickPickList { + export const condaEnvWithoutPythonTooltip = l10n.t( + 'Python is not available in this environment, it will automatically be installed upon selecting it', + ); export const noPythonInstalled = l10n.t('Python is not installed, please download and install it'); export const clickForInstructions = l10n.t('Click for instructions...'); export const globalGroupName = l10n.t('Global'); @@ -344,7 +351,7 @@ export namespace DebugConfigStrings { export const enterManagePyPath = { title: l10n.t('Debug Django'), prompt: l10n.t( - "Enter the path to manage.py ('${workspaceFolderToken}' points to the root of the current workspace folder)", + "Enter the path to manage.py ('${workspaceFolder}' points to the root of the current workspace folder)", ), invalid: l10n.t('Enter a valid Python file path'), }; @@ -400,9 +407,13 @@ export namespace Testing { export const testNotConfigured = l10n.t('No test framework configured.'); export const cancelUnittestDiscovery = l10n.t('Canceled unittest test discovery'); export const errorUnittestDiscovery = l10n.t('Unittest test discovery error'); + export const cancelPytestDiscovery = l10n.t('Canceled pytest test discovery'); + export const errorPytestDiscovery = l10n.t('pytest test discovery error'); export const seePythonOutput = l10n.t('(see Output > Python)'); export const cancelUnittestExecution = l10n.t('Canceled unittest test execution'); export const errorUnittestExecution = l10n.t('Unittest test execution error'); + export const cancelPytestExecution = l10n.t('Canceled pytest test execution'); + export const errorPytestExecution = l10n.t('Pytest test execution error'); } export namespace OutdatedDebugger { @@ -438,7 +449,11 @@ export namespace CreateEnv { export namespace Venv { export const creating = l10n.t('Creating venv...'); + export const creatingMicrovenv = l10n.t('Creating microvenv...'); export const created = l10n.t('Environment created...'); + export const existing = l10n.t('Using existing environment...'); + export const downloadingPip = l10n.t('Downloading pip...'); + export const installingPip = l10n.t('Installing pip...'); export const upgradingPip = l10n.t('Upgrading pip...'); export const installingPackages = l10n.t('Installing packages...'); export const errorCreatingEnvironment = l10n.t('Error while creating virtual environment.'); diff --git a/extensions/positron-python/src/client/common/utils/multiStepInput.ts b/extensions/positron-python/src/client/common/utils/multiStepInput.ts index 22559e0dbdd..e44879e8bbb 100644 --- a/extensions/positron-python/src/client/common/utils/multiStepInput.ts +++ b/extensions/positron-python/src/client/common/utils/multiStepInput.ts @@ -76,7 +76,7 @@ interface InputBoxParameters { validate(value: string): Promise; } -type MultiStepInputQuickPicResponseType = T | (P extends { buttons: (infer I)[] } ? I : never) | undefined; +type MultiStepInputQuickPickResponseType = T | (P extends { buttons: (infer I)[] } ? I : never) | undefined; type MultiStepInputInputBoxResponseType

= string | (P extends { buttons: (infer I)[] } ? I : never) | undefined; export interface IMultiStepInput { run(start: InputStep, state: S): Promise; @@ -88,7 +88,7 @@ export interface IMultiStepInput { activeItem, placeholder, customButtonSetups, - }: P): Promise>; + }: P): Promise>; showInputBox

({ title, step, @@ -126,7 +126,7 @@ export class MultiStepInput implements IMultiStepInput { keepScrollPosition, sortByLabel, initialize, - }: P): Promise> { + }: P): Promise> { const disposables: Disposable[] = []; const input = this.shell.createQuickPick(); input.title = title; diff --git a/extensions/positron-python/src/client/common/variables/environment.ts b/extensions/positron-python/src/client/common/variables/environment.ts index 06d3ef3af41..81e6b8b2cfc 100644 --- a/extensions/positron-python/src/client/common/variables/environment.ts +++ b/extensions/positron-python/src/client/common/variables/environment.ts @@ -10,6 +10,7 @@ import { EventName } from '../../telemetry/constants'; import { IFileSystem } from '../platform/types'; import { IPathUtils } from '../types'; import { EnvironmentVariables, IEnvironmentVariablesService } from './types'; +import { normCase } from '../platform/fs-paths'; @injectable() export class EnvironmentVariablesService implements IEnvironmentVariablesService { @@ -56,20 +57,25 @@ export class EnvironmentVariablesService implements IEnvironmentVariablesService public mergeVariables( source: EnvironmentVariables, target: EnvironmentVariables, - options?: { overwrite?: boolean }, + options?: { overwrite?: boolean; mergeAll?: boolean }, ) { if (!target) { return; } + const reference = target; + target = normCaseKeys(target); + source = normCaseKeys(source); const settingsNotToMerge = ['PYTHONPATH', this.pathVariable]; Object.keys(source).forEach((setting) => { - if (settingsNotToMerge.indexOf(setting) >= 0) { + if (!options?.mergeAll && settingsNotToMerge.indexOf(setting) >= 0) { return; } if (target[setting] === undefined || options?.overwrite) { target[setting] = source[setting]; } }); + restoreKeys(target); + matchTarget(reference, target); } public appendPythonPath(vars: EnvironmentVariables, ...pythonPaths: string[]) { @@ -80,18 +86,24 @@ export class EnvironmentVariablesService implements IEnvironmentVariablesService return this.appendPaths(vars, this.pathVariable, ...paths); } - private get pathVariable(): 'Path' | 'PATH' { + private get pathVariable(): string { if (!this._pathVariable) { this._pathVariable = this.pathUtils.getPathVariableName(); } - return this._pathVariable!; + return normCase(this._pathVariable)!; } - private appendPaths( - vars: EnvironmentVariables, - variableName: 'PATH' | 'Path' | 'PYTHONPATH', - ...pathsToAppend: string[] - ) { + private appendPaths(vars: EnvironmentVariables, variableName: string, ...pathsToAppend: string[]) { + const reference = vars; + vars = normCaseKeys(vars); + variableName = normCase(variableName); + vars = this._appendPaths(vars, variableName, ...pathsToAppend); + restoreKeys(vars); + matchTarget(reference, vars); + return vars; + } + + private _appendPaths(vars: EnvironmentVariables, variableName: string, ...pathsToAppend: string[]) { const valueToAppend = pathsToAppend .filter((item) => typeof item === 'string' && item.trim().length > 0) .map((item) => item.trim()) @@ -183,3 +195,40 @@ function substituteEnvVars( return value.replace(/\\\$/g, '$'); } + +export function normCaseKeys(env: EnvironmentVariables): EnvironmentVariables { + const normalizedEnv: EnvironmentVariables = {}; + Object.keys(env).forEach((key) => { + const normalizedKey = normCase(key); + normalizedEnv[normalizedKey] = env[key]; + }); + return normalizedEnv; +} + +export function restoreKeys(env: EnvironmentVariables) { + const processEnvKeys = Object.keys(process.env); + processEnvKeys.forEach((processEnvKey) => { + const originalKey = normCase(processEnvKey); + if (originalKey !== processEnvKey && env[originalKey] !== undefined) { + env[processEnvKey] = env[originalKey]; + delete env[originalKey]; + } + }); +} + +export function matchTarget(reference: EnvironmentVariables, target: EnvironmentVariables): void { + Object.keys(reference).forEach((key) => { + if (target.hasOwnProperty(key)) { + reference[key] = target[key]; + } else { + delete reference[key]; + } + }); + + // Add any new keys from target to reference + Object.keys(target).forEach((key) => { + if (!reference.hasOwnProperty(key)) { + reference[key] = target[key]; + } + }); +} diff --git a/extensions/positron-python/src/client/common/variables/types.ts b/extensions/positron-python/src/client/common/variables/types.ts index e4c301db7dd..252a0d48038 100644 --- a/extensions/positron-python/src/client/common/variables/types.ts +++ b/extensions/positron-python/src/client/common/variables/types.ts @@ -10,7 +10,11 @@ export const IEnvironmentVariablesService = Symbol('IEnvironmentVariablesService export interface IEnvironmentVariablesService { parseFile(filePath?: string, baseVars?: EnvironmentVariables): Promise; parseFileSync(filePath?: string, baseVars?: EnvironmentVariables): EnvironmentVariables | undefined; - mergeVariables(source: EnvironmentVariables, target: EnvironmentVariables, options?: { overwrite?: boolean }): void; + mergeVariables( + source: EnvironmentVariables, + target: EnvironmentVariables, + options?: { overwrite?: boolean; mergeAll?: boolean }, + ): void; appendPythonPath(vars: EnvironmentVariables, ...pythonPaths: string[]): void; appendPath(vars: EnvironmentVariables, ...paths: string[]): void; } diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/debugConfigurationService.ts b/extensions/positron-python/src/client/debugger/extension/configuration/debugConfigurationService.ts index ea0c84f7c3d..80a1e3a8a8c 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/debugConfigurationService.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/debugConfigurationService.ts @@ -114,64 +114,68 @@ export class PythonDebugConfigurationService implements IDebugConfigurationServi input: MultiStepInput, state: DebugConfigurationState, ): Promise | void> { - type DebugConfigurationQuickPickItem = QuickPickItem & { type: DebugConfigurationType }; + type DebugConfigurationQuickPickItemFunc = ( + input: MultiStepInput, + state: DebugConfigurationState, + ) => Promise>; + type DebugConfigurationQuickPickItem = QuickPickItem & { + type: DebugConfigurationType; + func: DebugConfigurationQuickPickItemFunc; + }; const items: DebugConfigurationQuickPickItem[] = [ { + func: buildFileLaunchDebugConfiguration, label: DebugConfigStrings.file.selectConfiguration.label, type: DebugConfigurationType.launchFile, description: DebugConfigStrings.file.selectConfiguration.description, }, { + func: buildModuleLaunchConfiguration, label: DebugConfigStrings.module.selectConfiguration.label, type: DebugConfigurationType.launchModule, description: DebugConfigStrings.module.selectConfiguration.description, }, { + func: buildRemoteAttachConfiguration, label: DebugConfigStrings.attach.selectConfiguration.label, type: DebugConfigurationType.remoteAttach, description: DebugConfigStrings.attach.selectConfiguration.description, }, { + func: buildPidAttachConfiguration, label: DebugConfigStrings.attachPid.selectConfiguration.label, type: DebugConfigurationType.pidAttach, description: DebugConfigStrings.attachPid.selectConfiguration.description, }, { + func: buildDjangoLaunchDebugConfiguration, label: DebugConfigStrings.django.selectConfiguration.label, type: DebugConfigurationType.launchDjango, description: DebugConfigStrings.django.selectConfiguration.description, }, { + func: buildFastAPILaunchDebugConfiguration, label: DebugConfigStrings.fastapi.selectConfiguration.label, type: DebugConfigurationType.launchFastAPI, description: DebugConfigStrings.fastapi.selectConfiguration.description, }, { + func: buildFlaskLaunchDebugConfiguration, label: DebugConfigStrings.flask.selectConfiguration.label, type: DebugConfigurationType.launchFlask, description: DebugConfigStrings.flask.selectConfiguration.description, }, { + func: buildPyramidLaunchConfiguration, label: DebugConfigStrings.pyramid.selectConfiguration.label, type: DebugConfigurationType.launchPyramid, description: DebugConfigStrings.pyramid.selectConfiguration.description, }, ]; - const debugConfigurations = new Map< - DebugConfigurationType, - ( - input: MultiStepInput, - state: DebugConfigurationState, - ) => Promise> - >(); - debugConfigurations.set(DebugConfigurationType.launchDjango, buildDjangoLaunchDebugConfiguration); - debugConfigurations.set(DebugConfigurationType.launchFastAPI, buildFastAPILaunchDebugConfiguration); - debugConfigurations.set(DebugConfigurationType.launchFile, buildFileLaunchDebugConfiguration); - debugConfigurations.set(DebugConfigurationType.launchFlask, buildFlaskLaunchDebugConfiguration); - debugConfigurations.set(DebugConfigurationType.launchModule, buildModuleLaunchConfiguration); - debugConfigurations.set(DebugConfigurationType.pidAttach, buildPidAttachConfiguration); - debugConfigurations.set(DebugConfigurationType.remoteAttach, buildRemoteAttachConfiguration); - debugConfigurations.set(DebugConfigurationType.launchPyramid, buildPyramidLaunchConfiguration); + const debugConfigurations = new Map(); + for (const config of items) { + debugConfigurations.set(config.type, config.func); + } state.config = {}; const pick = await input.showQuickPick< diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/providers/fileLaunch.ts b/extensions/positron-python/src/client/debugger/extension/configuration/providers/fileLaunch.ts index 746e33ea86f..edda7ed7e22 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/providers/fileLaunch.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/providers/fileLaunch.ts @@ -24,7 +24,7 @@ export async function buildFileLaunchDebugConfiguration( justMyCode: true, }; sendTelemetryEvent(EventName.DEBUGGER_CONFIGURATION_PROMPTS, undefined, { - configurationType: DebugConfigurationType.launchFastAPI, + configurationType: DebugConfigurationType.launchFile, }); Object.assign(state.config, config); } diff --git a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts index 0ccaa996405..15be5f97538 100644 --- a/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts +++ b/extensions/positron-python/src/client/debugger/extension/configuration/resolvers/helper.ts @@ -44,11 +44,14 @@ export class DebugEnvironmentVariablesHelper implements IDebugEnvironmentVariabl // take precedence over env file. this.envParser.mergeVariables(debugLaunchEnvVars, env, { overwrite: true }); if (baseVars) { - this.envParser.mergeVariables(baseVars, env); + this.envParser.mergeVariables(baseVars, env, { mergeAll: true }); } // Append the PYTHONPATH and PATH variables. - this.envParser.appendPath(env, debugLaunchEnvVars[pathVariableName]); + this.envParser.appendPath( + env, + debugLaunchEnvVars[pathVariableName] ?? debugLaunchEnvVars[pathVariableName.toUpperCase()], + ); this.envParser.appendPythonPath(env, debugLaunchEnvVars.PYTHONPATH); if (typeof env[pathVariableName] === 'string' && env[pathVariableName]!.length > 0) { 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 6a28075d435..f48b2c19aaf 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,6 +19,8 @@ import { getProgram, IDebugEnvironmentVariablesService } from './helper'; @injectable() export class LaunchConfigurationResolver extends BaseConfigurationResolver { + private isPythonSet = false; + constructor( @inject(IDiagnosticsService) @named(InvalidPythonPathInDebuggerServiceId) @@ -36,6 +38,7 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver { + this.isPythonSet = debugConfiguration.python !== undefined; if ( debugConfiguration.name === undefined && debugConfiguration.type === undefined && @@ -84,7 +87,6 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver { - const isPythonSet = debugConfiguration.python !== undefined; if (debugConfiguration.python === undefined) { debugConfiguration.python = debugConfiguration.pythonPath; } @@ -104,7 +106,7 @@ export class LaunchConfigurationResolver extends BaseConfigurationResolver { const extensions = serviceContainer.get(IExtensions); await setDefaultLanguageServer(extensions, serviceManager); - // Note we should not trigger any extension related code which logs, until we have set logging level. So we cannot - // use configurations service to get level setting. Instead, we use Workspace service to query for setting as it - // directly queries VSCode API. - setLoggingLevel(getLoggingLevel()); - const configuration = serviceManager.get(IConfigurationService); // Settings are dependent on Experiment service, so we need to initialize it after experiments are activated. serviceContainer.get(IConfigurationService).getSettings().register(); @@ -173,7 +159,7 @@ async function activateLegacy(ext: ExtensionState): Promise { const dispatcher = new DebugSessionEventDispatcher(handlers, DebugService.instance, disposables); dispatcher.registerEventHandlers(); - const outputChannel = serviceManager.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); + const outputChannel = serviceManager.get(ILogOutputChannel); disposables.push(cmdManager.registerCommand(Commands.ViewOutput, () => outputChannel.show())); cmdManager.executeCommand('setContext', 'python.vscode.channel', applicationEnv.channel).then(noop, noop); diff --git a/extensions/positron-python/src/client/extensionInit.ts b/extensions/positron-python/src/client/extensionInit.ts index 4ee5f099f15..851bc943cb8 100644 --- a/extensions/positron-python/src/client/extensionInit.ts +++ b/extensions/positron-python/src/client/extensionInit.ts @@ -4,9 +4,8 @@ 'use strict'; import { Container } from 'inversify'; -import { Disposable, Memento, OutputChannel, window } from 'vscode'; +import { Disposable, Memento, window } from 'vscode'; import { instance, mock } from 'ts-mockito'; -import { STANDARD_OUTPUT_CHANNEL } from './common/constants'; import { registerTypes as platformRegisterTypes } from './common/platform/serviceRegistry'; import { registerTypes as processRegisterTypes } from './common/process/serviceRegistry'; import { registerTypes as commonRegisterTypes } from './common/serviceRegistry'; @@ -16,7 +15,8 @@ import { IDisposableRegistry, IExtensionContext, IMemento, - IOutputChannel, + ILogOutputChannel, + ITestOutputChannel, WORKSPACE_MEMENTO, } from './common/types'; import { registerTypes as variableRegisterTypes } from './common/variables/serviceRegistry'; @@ -26,7 +26,6 @@ import { ServiceContainer } from './ioc/container'; import { ServiceManager } from './ioc/serviceManager'; import { IServiceContainer, IServiceManager } from './ioc/types'; import * as pythonEnvironments from './pythonEnvironments'; -import { TEST_OUTPUT_CHANNEL } from './testing/constants'; import { IDiscoveryAPI } from './pythonEnvironments/base/locator'; import { registerLogger } from './logging'; import { OutputChannelLogger } from './logging/outputChannelLogger'; @@ -54,7 +53,7 @@ export function initializeGlobals( serviceManager.addSingletonInstance(IMemento, context.workspaceState, WORKSPACE_MEMENTO); serviceManager.addSingletonInstance(IExtensionContext, context); - const standardOutputChannel = window.createOutputChannel(OutputChannelNames.python); + const standardOutputChannel = window.createOutputChannel(OutputChannelNames.python, { log: true }); disposables.push(standardOutputChannel); disposables.push(registerLogger(new OutputChannelLogger(standardOutputChannel))); @@ -62,12 +61,12 @@ export function initializeGlobals( const unitTestOutChannel = workspaceService.isVirtualWorkspace || !workspaceService.isTrusted ? // Do not create any test related output UI when using virtual workspaces. - instance(mock()) + instance(mock()) : window.createOutputChannel(OutputChannelNames.pythonTest); disposables.push(unitTestOutChannel); - serviceManager.addSingletonInstance(IOutputChannel, standardOutputChannel, STANDARD_OUTPUT_CHANNEL); - serviceManager.addSingletonInstance(IOutputChannel, unitTestOutChannel, TEST_OUTPUT_CHANNEL); + serviceManager.addSingletonInstance(ILogOutputChannel, standardOutputChannel); + serviceManager.addSingletonInstance(ITestOutputChannel, unitTestOutChannel); return { context, diff --git a/extensions/positron-python/src/client/formatters/baseFormatter.ts b/extensions/positron-python/src/client/formatters/baseFormatter.ts index bffa03a8b1e..64e7d15a3d4 100644 --- a/extensions/positron-python/src/client/formatters/baseFormatter.ts +++ b/extensions/positron-python/src/client/formatters/baseFormatter.ts @@ -8,7 +8,7 @@ import { IPythonToolExecutionService } from '../common/process/types'; import { IDisposableRegistry, IInstaller, Product } from '../common/types'; import { isNotebookCell } from '../common/utils/misc'; import { IServiceContainer } from '../ioc/types'; -import { traceError, traceLog } from '../logging'; +import { traceError } from '../logging'; import { getTempFileWithDocumentContents, getTextEditsFromPatch } from './../common/editor'; import { IFormatterHelper } from './types'; import { IInstallFormatterPrompt } from '../providers/prompts/types'; @@ -16,6 +16,7 @@ import { IInstallFormatterPrompt } from '../providers/prompts/types'; export abstract class BaseFormatter { protected readonly workspace: IWorkspaceService; private readonly helper: IFormatterHelper; + private errorShown: boolean = false; constructor(public Id: string, private product: Product, protected serviceContainer: IServiceContainer) { this.helper = serviceContainer.get(IFormatterHelper); @@ -101,23 +102,22 @@ export abstract class BaseFormatter { } protected async handleError(_expectedFileName: string, error: Error, resource?: vscode.Uri) { - let customError = `Formatting with ${this.Id} failed.`; - if (isNotInstalledError(error)) { const prompt = this.serviceContainer.get(IInstallFormatterPrompt); if (!(await prompt.showInstallFormatterPrompt(resource))) { const installer = this.serviceContainer.get(IInstaller); const isInstalled = await installer.isInstalled(this.product, resource); - if (!isInstalled) { - customError += `\nYou could either install the '${this.Id}' formatter, turn it off or use another formatter.`; - installer - .promptToInstall(this.product, resource) - .catch((ex) => traceError('Python Extension: promptToInstall', ex)); + if (!isInstalled && !this.errorShown) { + traceError( + `\nPlease install '${this.Id}' into your environment.`, + "\nIf you don't want to use it you can turn it off or use another formatter in the settings.", + ); + this.errorShown = true; } } } - traceLog(`\n${customError}\n${error}`); + traceError(`Formatting with ${this.Id} failed:\n${error}`); } /** diff --git a/extensions/positron-python/src/client/interpreter/activation/service.ts b/extensions/positron-python/src/client/interpreter/activation/service.ts index 897dad9cb75..e5da57227b1 100644 --- a/extensions/positron-python/src/client/interpreter/activation/service.ts +++ b/extensions/positron-python/src/client/interpreter/activation/service.ts @@ -6,6 +6,7 @@ import '../../common/extensions'; +import * as path from 'path'; import { inject, injectable } from 'inversify'; import { IWorkspaceService } from '../../common/application/types'; @@ -36,6 +37,7 @@ import { import { Conda } from '../../pythonEnvironments/common/environmentManagers/conda'; import { StopWatch } from '../../common/utils/stopWatch'; import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector'; +import { getSearchPathEnvVarNames } from '../../common/utils/exec'; const ENVIRONMENT_PREFIX = 'e8b39361-0157-4923-80e1-22d70d46dee6'; const CACHE_DURATION = 10 * 60 * 1000; @@ -200,6 +202,11 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi shellInfo = { shellType: customShellType, shell }; } try { + const processService = await this.processServiceFactory.create(resource); + const customEnvVars = (await this.envVarsService.getEnvironmentVariables(resource)) ?? {}; + const hasCustomEnvVars = Object.keys(customEnvVars).length; + const env = hasCustomEnvVars ? customEnvVars : { ...this.currentProcess.env }; + let command: string | undefined; const [args, parse] = internalScripts.printEnvVariables(); args.forEach((arg, i) => { @@ -224,6 +231,16 @@ 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) { + const key = getSearchPathEnvVarNames()[0]; + if (env[key]) { + env[key] = `${path.dirname(interpreter.path)}${path.delimiter}${env[key]}`; + } else { + env[key] = `${path.dirname(interpreter.path)}`; + } + + return env; + } return undefined; } // Run the activate command collect the environment from it. @@ -233,11 +250,6 @@ export class EnvironmentActivationService implements IEnvironmentActivationServi command = `${activationCommand} && echo '${ENVIRONMENT_PREFIX}' && python ${args.join(' ')}`; } - const processService = await this.processServiceFactory.create(resource); - const customEnvVars = await this.envVarsService.getEnvironmentVariables(resource); - const hasCustomEnvVars = Object.keys(customEnvVars).length; - const env = hasCustomEnvVars ? customEnvVars : { ...this.currentProcess.env }; - // Make sure python warnings don't interfere with getting the environment. However // respect the warning in the returned values const oldWarnings = env[PYTHON_WARNINGS]; diff --git a/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts index f5af71b3f2c..26852303d09 100644 --- a/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts +++ b/extensions/positron-python/src/client/interpreter/activation/terminalEnvVarCollectionService.ts @@ -1,23 +1,33 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as path from 'path'; import { inject, injectable } from 'inversify'; -import { ProgressOptions, ProgressLocation } from 'vscode'; -import { IExtensionSingleActivationService } from '../../activation/types'; -import { IApplicationShell, IApplicationEnvironment } from '../../common/application/types'; +import { ProgressOptions, ProgressLocation, MarkdownString, WorkspaceFolder } from 'vscode'; +import { pathExists } from 'fs-extra'; +import { IExtensionActivationService } from '../../activation/types'; +import { IApplicationShell, IApplicationEnvironment, IWorkspaceService } from '../../common/application/types'; import { inTerminalEnvVarExperiment } from '../../common/experiments/helpers'; import { IPlatformService } from '../../common/platform/types'; import { identifyShellFromShellPath } from '../../common/terminal/shellDetectors/baseShellDetector'; -import { IExtensionContext, IExperimentService, Resource, IDisposableRegistry } from '../../common/types'; +import { + IExtensionContext, + IExperimentService, + Resource, + IDisposableRegistry, + IConfigurationService, + IPathUtils, +} from '../../common/types'; import { Deferred, createDeferred } from '../../common/utils/async'; import { Interpreters } from '../../common/utils/localize'; import { traceDecoratorVerbose, traceVerbose } from '../../logging'; import { IInterpreterService } from '../contracts'; import { defaultShells } from './service'; import { IEnvironmentActivationService } from './types'; +import { EnvironmentType } from '../../pythonEnvironments/info'; @injectable() -export class TerminalEnvVarCollectionService implements IExtensionSingleActivationService { +export class TerminalEnvVarCollectionService implements IExtensionActivationService { public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: false, @@ -25,6 +35,8 @@ export class TerminalEnvVarCollectionService implements IExtensionSingleActivati private deferred: Deferred | undefined; + private registeredOnce = false; + private previousEnvVars = _normCaseKeys(process.env); constructor( @@ -36,38 +48,60 @@ export class TerminalEnvVarCollectionService implements IExtensionSingleActivati @inject(IApplicationEnvironment) private applicationEnvironment: IApplicationEnvironment, @inject(IDisposableRegistry) private disposables: IDisposableRegistry, @inject(IEnvironmentActivationService) private environmentActivationService: IEnvironmentActivationService, + @inject(IWorkspaceService) private workspaceService: IWorkspaceService, + @inject(IConfigurationService) private readonly configurationService: IConfigurationService, + @inject(IPathUtils) private readonly pathUtils: IPathUtils, ) {} - public async activate(): Promise { + public async activate(resource: Resource): Promise { 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; } - this.interpreterService.onDidChangeInterpreter( - async (resource) => { - this.showProgress(); - await this._applyCollection(resource); - 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); - this.hideProgress(); - }, - this, - this.disposables, - ); - - this._applyCollection(undefined).ignoreErrors(); + 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(); } public async _applyCollection(resource: Resource, shell = this.applicationEnvironment.shell): Promise { + const workspaceFolder = this.getWorkspaceFolder(resource); + const settings = this.configurationService.getSettings(resource); + if (!settings.terminal.activateEnvironment) { + traceVerbose('Activating environments in terminal is disabled for', resource?.fsPath); + return; + } const env = await this.environmentActivationService.getActivatedEnvironmentVariables( resource, undefined, @@ -83,7 +117,7 @@ export class TerminalEnvVarCollectionService implements IExtensionSingleActivati await this._applyCollection(resource, defaultShell?.shell); return; } - this.context.environmentVariableCollection.clear(); + this.context.environmentVariableCollection.clear({ workspaceFolder }); this.previousEnvVars = _normCaseKeys(process.env); return; } @@ -95,10 +129,10 @@ export class TerminalEnvVarCollectionService implements IExtensionSingleActivati if (prevValue !== value) { if (value !== undefined) { traceVerbose(`Setting environment variable ${key} in collection to ${value}`); - this.context.environmentVariableCollection.replace(key, value); + this.context.environmentVariableCollection.replace(key, value, { workspaceFolder }); } else { traceVerbose(`Clearing environment variable ${key} from collection`); - this.context.environmentVariableCollection.delete(key); + this.context.environmentVariableCollection.delete(key, { workspaceFolder }); } } }); @@ -106,9 +140,45 @@ export class TerminalEnvVarCollectionService implements IExtensionSingleActivati // 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`); - this.context.environmentVariableCollection.delete(key); + this.context.environmentVariableCollection.delete(key, { workspaceFolder }); } }); + const displayPath = this.pathUtils.getDisplayName(settings.pythonPath, workspaceFolder?.uri.fsPath); + const description = new MarkdownString(`${Interpreters.activateTerminalDescription} \`${displayPath}\``); + this.context.environmentVariableCollection.setDescription(description, { + workspaceFolder, + }); + } + + private async handleMicroVenv(resource: Resource) { + 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))) { + this.context.environmentVariableCollection.replace( + 'PATH', + `${path.dirname(interpreter.path)}${path.delimiter}${process.env.Path}`, + { + workspaceFolder, + }, + ); + return; + } + } + this.context.environmentVariableCollection.clear(); + } + + private getWorkspaceFolder(resource: Resource): WorkspaceFolder | undefined { + let workspaceFolder = this.workspaceService.getWorkspaceFolder(resource); + if ( + !workspaceFolder && + Array.isArray(this.workspaceService.workspaceFolders) && + this.workspaceService.workspaceFolders.length > 0 + ) { + [workspaceFolder] = this.workspaceService.workspaceFolders; + } + return workspaceFolder; } @traceDecoratorVerbose('Display activating terminals') diff --git a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts index 9a1d643269e..c0876ff518d 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/interpreterSelector/commands/setInterpreter.ts @@ -415,6 +415,7 @@ export class SetInterpreterCommand extends BaseInterpreterSelectorCommand implem if (isInterpreterQuickPickItem(item) && isProblematicCondaEnvironment(item.interpreter)) { if (!items[i].label.includes(Octicons.Warning)) { items[i].label = `${Octicons.Warning} ${items[i].label}`; + items[i].tooltip = InterpreterQuickPickList.condaEnvWithoutPythonTooltip; } } }); diff --git a/extensions/positron-python/src/client/interpreter/configuration/types.ts b/extensions/positron-python/src/client/interpreter/configuration/types.ts index 90facb7fe64..2f3882e1246 100644 --- a/extensions/positron-python/src/client/interpreter/configuration/types.ts +++ b/extensions/positron-python/src/client/interpreter/configuration/types.ts @@ -52,11 +52,7 @@ export interface IInterpreterQuickPickItem extends QuickPickItem { interpreter: PythonEnvironment; } -export interface ISpecialQuickPickItem { - label: string; - description?: string; - detail?: string; - alwaysShow: boolean; +export interface ISpecialQuickPickItem extends QuickPickItem { path?: string; } diff --git a/extensions/positron-python/src/client/interpreter/serviceRegistry.ts b/extensions/positron-python/src/client/interpreter/serviceRegistry.ts index 15dd6de7b72..04af15415b0 100644 --- a/extensions/positron-python/src/client/interpreter/serviceRegistry.ts +++ b/extensions/positron-python/src/client/interpreter/serviceRegistry.ts @@ -109,8 +109,8 @@ export function registerTypes(serviceManager: IServiceManager): void { IEnvironmentActivationService, EnvironmentActivationService, ); - serviceManager.addSingleton( - IExtensionSingleActivationService, + serviceManager.addSingleton( + IExtensionActivationService, TerminalEnvVarCollectionService, ); } diff --git a/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts b/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts index 16da174f317..a0fa0fedb63 100644 --- a/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts +++ b/extensions/positron-python/src/client/jupyter/jupyterIntegration.ts @@ -8,7 +8,7 @@ import { inject, injectable, named } from 'inversify'; import { dirname } from 'path'; import { CancellationToken, Event, Extension, Memento, Uri } from 'vscode'; import type { SemVer } from 'semver'; -import { IWorkspaceService } from '../common/application/types'; +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 { @@ -35,6 +35,7 @@ import { 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. @@ -201,9 +202,11 @@ export class JupyterExtensionIntegration { @inject(IComponentAdapter) private pyenvs: IComponentAdapter, @inject(IWorkspaceService) private workspaceService: IWorkspaceService, @inject(ICondaService) private readonly condaService: ICondaService, + @inject(IContextKeyManager) private readonly contextManager: IContextKeyManager, ) {} public registerApi(jupyterExtensionApi: JupyterExtensionApi): JupyterExtensionApi | undefined { + this.contextManager.setContext(ExtensionContextKey.IsJupyterInstalled, true); if (!this.workspaceService.isTrusted) { this.workspaceService.onDidGrantWorkspaceTrust(() => this.registerApi(jupyterExtensionApi)); return undefined; diff --git a/extensions/positron-python/src/client/jupyter/requireJupyterPrompt.ts b/extensions/positron-python/src/client/jupyter/requireJupyterPrompt.ts new file mode 100644 index 00000000000..3e6878ba426 --- /dev/null +++ b/extensions/positron-python/src/client/jupyter/requireJupyterPrompt.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { inject, injectable } from 'inversify'; +import { IExtensionSingleActivationService } from '../activation/types'; +import { IApplicationShell, ICommandManager } from '../common/application/types'; +import { Common, Interpreters } from '../common/utils/localize'; +import { Commands, JUPYTER_EXTENSION_ID } from '../common/constants'; +import { IDisposable, IDisposableRegistry } from '../common/types'; +import { sendTelemetryEvent } from '../telemetry'; +import { EventName } from '../telemetry/constants'; + +@injectable() +export class RequireJupyterPrompt implements IExtensionSingleActivationService { + public readonly supportedWorkspaceTypes = { untrustedWorkspace: false, virtualWorkspace: true }; + + constructor( + @inject(IApplicationShell) private readonly appShell: IApplicationShell, + @inject(ICommandManager) private readonly commandManager: ICommandManager, + @inject(IDisposableRegistry) private readonly disposables: IDisposable[], + ) {} + + public async activate(): Promise { + this.disposables.push(this.commandManager.registerCommand(Commands.InstallJupyter, () => this._showPrompt())); + } + + public async _showPrompt(): Promise { + const prompts = [Common.bannerLabelYes, Common.bannerLabelNo]; + const telemetrySelections: ['Yes', 'No'] = ['Yes', 'No']; + const selection = await this.appShell.showInformationMessage(Interpreters.requireJupyter, ...prompts); + sendTelemetryEvent(EventName.REQUIRE_JUPYTER_PROMPT, undefined, { + selection: selection ? telemetrySelections[prompts.indexOf(selection)] : undefined, + }); + if (!selection) { + return; + } + if (selection === prompts[0]) { + await this.commandManager.executeCommand( + 'workbench.extensions.installExtension', + JUPYTER_EXTENSION_ID, + undefined, + ); + } + } +} diff --git a/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts b/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts index c7df3318e3b..7b03d909a51 100644 --- a/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts +++ b/extensions/positron-python/src/client/languageServer/pylanceLSExtensionManager.ts @@ -49,11 +49,7 @@ export class PylanceLSExtensionManager implements IDisposable, ILanguageServerEx private readonly extensions: IExtensions, readonly applicationShell: IApplicationShell, ) { - this.analysisOptions = new NodeLanguageServerAnalysisOptions( - outputChannel, - workspaceService, - experimentService, - ); + this.analysisOptions = new NodeLanguageServerAnalysisOptions(outputChannel, workspaceService); this.clientFactory = new NodeLanguageClientFactory(fileSystem, extensions); this.serverProxy = new NodeLanguageServerProxy( this.clientFactory, diff --git a/extensions/positron-python/src/client/linters/errorHandlers/standard.ts b/extensions/positron-python/src/client/linters/errorHandlers/standard.ts index 6bd2d3c8e11..f6e04b50ff1 100644 --- a/extensions/positron-python/src/client/linters/errorHandlers/standard.ts +++ b/extensions/positron-python/src/client/linters/errorHandlers/standard.ts @@ -1,7 +1,6 @@ import { l10n, Uri } from 'vscode'; import { IApplicationShell } from '../../common/application/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../common/constants'; -import { ExecutionInfo, IOutputChannel } from '../../common/types'; +import { ExecutionInfo, ILogOutputChannel } from '../../common/types'; import { traceError, traceLog } from '../../logging'; import { ILinterManager, LinterId } from '../types'; import { BaseErrorHandler } from './baseErrorHandler'; @@ -29,7 +28,7 @@ export class StandardErrorHandler extends BaseErrorHandler { private async displayLinterError(linterId: LinterId) { const message = l10n.t("There was an error in running the linter '{0}'", linterId); const appShell = this.serviceContainer.get(IApplicationShell); - const outputChannel = this.serviceContainer.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); + const outputChannel = this.serviceContainer.get(ILogOutputChannel); const action = await appShell.showErrorMessage(message, 'View Errors'); if (action === 'View Errors') { outputChannel.show(); diff --git a/extensions/positron-python/src/client/logging/index.ts b/extensions/positron-python/src/client/logging/index.ts index b28cadc7468..39d5652e100 100644 --- a/extensions/positron-python/src/client/logging/index.ts +++ b/extensions/positron-python/src/client/logging/index.ts @@ -11,7 +11,7 @@ import { StopWatch } from '../common/utils/stopWatch'; import { sendTelemetryEvent } from '../telemetry'; import { EventName } from '../telemetry/constants'; import { FileLogger } from './fileLogger'; -import { Arguments, ILogging, LoggingLevelSettingType, LogLevel, TraceDecoratorType, TraceOptions } from './types'; +import { Arguments, ILogging, LogLevel, TraceDecoratorType, TraceOptions } from './types'; import { argsToLogString, returnValueToLogString } from './util'; const DEFAULT_OPTS: TraceOptions = TraceOptions.Arguments | TraceOptions.ReturnValue; @@ -26,21 +26,6 @@ export function registerLogger(logger: ILogging): Disposable { }; } -const logLevelMap: Map = new Map([ - ['error', LogLevel.Error], - ['warn', LogLevel.Warn], - ['info', LogLevel.Info], - ['debug', LogLevel.Debug], - ['none', LogLevel.Off], - ['off', LogLevel.Off], - [undefined, LogLevel.Error], -]); - -let globalLoggingLevel: LogLevel; -export function setLoggingLevel(level?: LoggingLevelSettingType): void { - globalLoggingLevel = logLevelMap.get(level) ?? LogLevel.Error; -} - export function initializeFileLogging(disposables: Disposable[]): void { if (process.env.VSC_PYTHON_LOG_FILE) { const fileLogger = new FileLogger(createWriteStream(process.env.VSC_PYTHON_LOG_FILE)); @@ -54,27 +39,19 @@ export function traceLog(...args: Arguments): void { } export function traceError(...args: Arguments): void { - if (globalLoggingLevel >= LogLevel.Error) { - loggers.forEach((l) => l.traceError(...args)); - } + loggers.forEach((l) => l.traceError(...args)); } export function traceWarn(...args: Arguments): void { - if (globalLoggingLevel >= LogLevel.Warn) { - loggers.forEach((l) => l.traceWarn(...args)); - } + loggers.forEach((l) => l.traceWarn(...args)); } export function traceInfo(...args: Arguments): void { - if (globalLoggingLevel >= LogLevel.Info) { - loggers.forEach((l) => l.traceInfo(...args)); - } + loggers.forEach((l) => l.traceInfo(...args)); } export function traceVerbose(...args: Arguments): void { - if (globalLoggingLevel >= LogLevel.Debug) { - loggers.forEach((l) => l.traceVerbose(...args)); - } + loggers.forEach((l) => l.traceVerbose(...args)); } /** Logging Decorators go here */ @@ -89,7 +66,7 @@ export function traceDecoratorInfo(message: string): TraceDecoratorType { return createTracingDecorator({ message, opts: DEFAULT_OPTS, level: LogLevel.Info }); } export function traceDecoratorWarn(message: string): TraceDecoratorType { - return createTracingDecorator({ message, opts: DEFAULT_OPTS, level: LogLevel.Warn }); + return createTracingDecorator({ message, opts: DEFAULT_OPTS, level: LogLevel.Warning }); } // Information about a function/method call. @@ -223,7 +200,7 @@ export function logTo(logLevel: LogLevel, ...args: Arguments): void { case LogLevel.Error: traceError(...args); break; - case LogLevel.Warn: + case LogLevel.Warning: traceWarn(...args); break; case LogLevel.Info: diff --git a/extensions/positron-python/src/client/logging/outputChannelLogger.ts b/extensions/positron-python/src/client/logging/outputChannelLogger.ts index 27ea0031c01..40505d33a73 100644 --- a/extensions/positron-python/src/client/logging/outputChannelLogger.ts +++ b/extensions/positron-python/src/client/logging/outputChannelLogger.ts @@ -2,34 +2,29 @@ // Licensed under the MIT License. import * as util from 'util'; -import { OutputChannel } from 'vscode'; +import { LogOutputChannel } from 'vscode'; import { Arguments, ILogging } from './types'; -import { getTimeForLogging } from './util'; - -function formatMessage(level?: string, ...data: Arguments): string { - return level ? `[${level.toUpperCase()} ${getTimeForLogging()}]: ${util.format(...data)}` : util.format(...data); -} export class OutputChannelLogger implements ILogging { - constructor(private readonly channel: OutputChannel) {} + constructor(private readonly channel: LogOutputChannel) {} public traceLog(...data: Arguments): void { this.channel.appendLine(util.format(...data)); } public traceError(...data: Arguments): void { - this.channel.appendLine(formatMessage('error', ...data)); + this.channel.error(util.format(...data)); } public traceWarn(...data: Arguments): void { - this.channel.appendLine(formatMessage('warn', ...data)); + this.channel.warn(util.format(...data)); } public traceInfo(...data: Arguments): void { - this.channel.appendLine(formatMessage('info', ...data)); + this.channel.info(util.format(...data)); } public traceVerbose(...data: Arguments): void { - this.channel.appendLine(formatMessage('debug', ...data)); + this.channel.debug(util.format(...data)); } } diff --git a/extensions/positron-python/src/client/logging/settings.ts b/extensions/positron-python/src/client/logging/settings.ts deleted file mode 100644 index 97dce79700d..00000000000 --- a/extensions/positron-python/src/client/logging/settings.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -import { LoggingLevelSettingType } from './types'; -import { WorkspaceService } from '../common/application/workspace'; - -/** - * Uses Workspace service to query for `python.logging.level` setting and returns it. - */ -export function getLoggingLevel(): LoggingLevelSettingType | 'off' { - const workspace = new WorkspaceService(); - return workspace.getConfiguration('python').get('logging.level') ?? 'error'; -} diff --git a/extensions/positron-python/src/client/logging/types.ts b/extensions/positron-python/src/client/logging/types.ts index 92b514d218f..c0580086851 100644 --- a/extensions/positron-python/src/client/logging/types.ts +++ b/extensions/positron-python/src/client/logging/types.ts @@ -4,17 +4,17 @@ /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ -export type LoggingLevelSettingType = 'off' | 'error' | 'warn' | 'info' | 'debug'; +export type Arguments = unknown[]; + export enum LogLevel { Off = 0, - Error = 10, - Warn = 20, - Info = 30, - Debug = 40, + Trace = 1, + Debug = 2, + Info = 3, + Warning = 4, + Error = 5, } -export type Arguments = unknown[]; - export interface ILogging { traceLog(...data: Arguments): void; traceError(...data: Arguments): void; diff --git a/extensions/positron-python/src/client/proposedApiTypes.ts b/extensions/positron-python/src/client/proposedApiTypes.ts index 1b772b40664..13ad5af543e 100644 --- a/extensions/positron-python/src/client/proposedApiTypes.ts +++ b/extensions/positron-python/src/client/proposedApiTypes.ts @@ -1,4 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -export interface ProposedExtensionAPI {} +export interface ProposedExtensionAPI { + /** + * Top level proposed APIs should go here. + */ +} diff --git a/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts b/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts index 5e9ff7f818e..5743f840205 100644 --- a/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts +++ b/extensions/positron-python/src/client/providers/prompts/installFormatterPrompt.ts @@ -21,17 +21,21 @@ const SHOW_FORMATTER_INSTALL_PROMPT_DONOTSHOW_KEY = 'showFormatterExtensionInsta @injectable() export class InstallFormatterPrompt implements IInstallFormatterPrompt { - private shownThisSession = false; + private currentlyShown = false; constructor(@inject(IServiceContainer) private readonly serviceContainer: IServiceContainer) {} + /* + * This method is called when the user saves a python file or a cell. + * Returns true if an extension was selected. Otherwise returns false. + */ public async showInstallFormatterPrompt(resource?: Uri): Promise { if (!inFormatterExtensionExperiment(this.serviceContainer)) { return false; } const promptState = doNotShowPromptState(SHOW_FORMATTER_INSTALL_PROMPT_DONOTSHOW_KEY, this.serviceContainer); - if (this.shownThisSession || promptState.value) { + if (this.currentlyShown || promptState.value) { return false; } @@ -53,7 +57,7 @@ export class InstallFormatterPrompt implements IInstallFormatterPrompt { let selection: string | undefined; if (black || autopep8) { - this.shownThisSession = true; + this.currentlyShown = true; if (black && autopep8) { selection = await showInformationMessage( ToolsExtensions.selectMultipleFormattersPrompt, @@ -81,7 +85,7 @@ export class InstallFormatterPrompt implements IInstallFormatterPrompt { } } } else if (formatter === 'black' && !black) { - this.shownThisSession = true; + this.currentlyShown = true; selection = await showInformationMessage( ToolsExtensions.installBlackFormatterPrompt, 'Black', @@ -89,7 +93,7 @@ export class InstallFormatterPrompt implements IInstallFormatterPrompt { Common.doNotShowAgain, ); } else if (formatter === 'autopep8' && !autopep8) { - this.shownThisSession = true; + this.currentlyShown = true; selection = await showInformationMessage( ToolsExtensions.installAutopep8FormatterPrompt, 'Black', @@ -98,23 +102,32 @@ export class InstallFormatterPrompt implements IInstallFormatterPrompt { ); } + let userSelectedAnExtension = false; if (selection === 'Black') { if (black) { + userSelectedAnExtension = true; await updateDefaultFormatter(BLACK_EXTENSION, resource); } else { + userSelectedAnExtension = true; await installFormatterExtension(BLACK_EXTENSION, resource); } } else if (selection === 'Autopep8') { if (autopep8) { + userSelectedAnExtension = true; await updateDefaultFormatter(AUTOPEP8_EXTENSION, resource); } else { + userSelectedAnExtension = true; await installFormatterExtension(AUTOPEP8_EXTENSION, resource); } } else if (selection === Common.doNotShowAgain) { + userSelectedAnExtension = false; await promptState.updateValue(true); + } else { + userSelectedAnExtension = false; } - return this.shownThisSession; + this.currentlyShown = false; + return userSelectedAnExtension; } } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts b/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts index d3d3a252c99..6a981d21b6d 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/info/environmentInfoService.ts @@ -7,7 +7,7 @@ import { createDeferred, Deferred, sleep } from '../../../common/utils/async'; import { createRunningWorkerPool, IWorkerPool, QueuePosition } from '../../../common/utils/workerPool'; import { getInterpreterInfo, InterpreterInformation } from './interpreter'; import { buildPythonExecInfo } from '../../exec'; -import { traceError, traceInfo, traceWarn } from '../../../logging'; +import { traceError, traceVerbose, traceWarn } from '../../../logging'; import { Conda, CONDA_ACTIVATION_TIMEOUT, isCondaEnvironment } from '../../common/environmentManagers/conda'; import { PythonEnvInfo, PythonEnvKind } from '.'; import { normCasePath } from '../../common/externalDependencies'; @@ -153,7 +153,7 @@ class EnvironmentInfoService implements IEnvironmentInfoService { // as complete env info may not be available at this time. const isCondaEnv = env.kind === PythonEnvKind.Conda || (await isCondaEnvironment(env.executable.filename)); if (isCondaEnv) { - traceInfo( + traceVerbose( `Validating ${env.executable.filename} normally failed with error, falling back to using conda run: (${reason})`, ); if (this.condaRunWorkerPool === undefined) { diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts b/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts index 5341a8f561a..d0cb1f13f8f 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/info/interpreter.ts @@ -8,7 +8,7 @@ import { InterpreterInfoJson, } from '../../../common/process/internal/scripts'; import { Architecture } from '../../../common/utils/platform'; -import { traceError, traceInfo } from '../../../logging'; +import { traceError, traceVerbose } from '../../../logging'; import { shellExecute } from '../../common/externalDependencies'; import { copyPythonExecInfo, PythonExecInfo } from '../../exec'; import { parseVersion } from './pythonVersion'; @@ -102,6 +102,6 @@ export async function getInterpreterInfo( traceError(`Failed to parse interpreter information for >> ${quoted} << with ${ex}`); return undefined; } - traceInfo(`Found interpreter for >> ${quoted} <<: ${JSON.stringify(json)}`); + traceVerbose(`Found interpreter for >> ${quoted} <<: ${JSON.stringify(json)}`); return extractInterpreterInfo(python.pythonExecutable, json); } diff --git a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts index d3ece41f163..dadab2512e1 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/base/locators/composite/envsCollectionCache.ts @@ -3,7 +3,7 @@ import { Event } from 'vscode'; import { isTestExecution } from '../../../../common/constants'; -import { traceInfo, traceVerbose } from '../../../../logging'; +import { traceVerbose } from '../../../../logging'; import { arePathsSame, getFileInfo, pathExists } from '../../../common/externalDependencies'; import { PythonEnvInfo, PythonEnvKind } from '../../info'; import { areEnvsDeepEqual, areSameEnv, getEnvPath } from '../../info/env'; @@ -225,7 +225,7 @@ export class PythonEnvInfoCache extends PythonEnvsWatcher p.id === provider.id).length > 0) { + throw new Error(`Create Environment provider with id ${provider.id} already registered`); + } this._createEnvProviders.push(provider); } @@ -63,15 +69,43 @@ export function registerCreateEnvironmentFeatures( return handleCreateEnvironmentCommand(providers, options); }, ), - ); - disposables.push(registerCreateEnvironmentProvider(new VenvCreationProvider(interpreterQuickPick))); - disposables.push(registerCreateEnvironmentProvider(condaCreationProvider())); - disposables.push( - onCreateEnvironmentExited(async (e: CreateEnvironmentExitedEventArgs) => { - if (e.result?.path && e.options?.selectEnvironment) { - await interpreterPathService.update(e.result.uri, ConfigurationTarget.WorkspaceFolder, e.result.path); - showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.result.path)}`); + registerCommand( + Commands.Create_Environment_Button, + async (): Promise => { + sendTelemetryEvent(EventName.ENVIRONMENT_BUTTON, undefined, undefined); + await executeCommand(Commands.Create_Environment); + }, + ), + registerCreateEnvironmentProvider(new VenvCreationProvider(interpreterQuickPick)), + registerCreateEnvironmentProvider(condaCreationProvider()), + onCreateEnvironmentExited(async (e: EnvironmentDidCreateEvent) => { + if (e.path && e.options?.selectEnvironment) { + await interpreterPathService.update( + e.workspaceFolder?.uri, + ConfigurationTarget.WorkspaceFolder, + e.path, + ); + showInformationMessage(`${CreateEnv.informEnvCreation} ${pathUtils.getDisplayName(e.path)}`); } }), ); } + +export function buildEnvironmentCreationApi(): ProposedCreateEnvironmentAPI { + return { + onWillCreateEnvironment: onCreateEnvironmentStarted, + onDidCreateEnvironment: onCreateEnvironmentExited, + createEnvironment: async ( + options?: CreateEnvironmentOptions | undefined, + ): Promise => { + const providers = _createEnvironmentProviders.getAll(); + try { + return await handleCreateEnvironmentCommand(providers, options); + } catch (err) { + return { path: undefined, workspaceFolder: undefined, action: undefined, error: err as Error }; + } + }, + registerCreateEnvironmentProvider: (provider: CreateEnvironmentProvider) => + registerCreateEnvironmentProvider(provider), + }; +} diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts index bdeaf89ba82..4593ff1abf9 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/createEnvironment.ts @@ -11,15 +11,15 @@ import { } from '../../common/vscodeApis/windowApis'; import { traceError, traceVerbose } from '../../logging'; import { - CreateEnvironmentExitedEventArgs, CreateEnvironmentOptions, - CreateEnvironmentProvider, CreateEnvironmentResult, - CreateEnvironmentStartedEventArgs, -} from './types'; + CreateEnvironmentProvider, + EnvironmentWillCreateEvent, + EnvironmentDidCreateEvent, +} from './proposed.createEnvApis'; -const onCreateEnvironmentStartedEvent = new EventEmitter(); -const onCreateEnvironmentExitedEvent = new EventEmitter(); +const onCreateEnvironmentStartedEvent = new EventEmitter(); +const onCreateEnvironmentExitedEvent = new EventEmitter(); let startedEventCount = 0; @@ -32,14 +32,20 @@ function fireStartedEvent(options?: CreateEnvironmentOptions): void { startedEventCount += 1; } -function fireExitedEvent(result?: CreateEnvironmentResult, options?: CreateEnvironmentOptions, error?: unknown): void { - onCreateEnvironmentExitedEvent.fire({ result, options, error }); +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; } export function getCreationEvents(): { - onCreateEnvironmentStarted: Event; - onCreateEnvironmentExited: Event; + onCreateEnvironmentStarted: Event; + onCreateEnvironmentExited: Event; isCreatingEnvironment: () => boolean; } { return { @@ -54,7 +60,7 @@ async function createEnvironment( options: CreateEnvironmentOptions, ): Promise { let result: CreateEnvironmentResult | undefined; - let err: unknown | undefined; + let err: Error | undefined; try { fireStartedEvent(options); result = await provider.createEnvironment(options); @@ -65,7 +71,7 @@ async function createEnvironment( return undefined; } } - err = ex; + err = ex as Error; throw err; } finally { fireExitedEvent(result, options, err); @@ -185,11 +191,7 @@ export async function handleCreateEnvironmentCommand( const action = await MultiStepNode.run(envTypeStep); if (options?.showBackButton) { if (action === MultiStepAction.Back || action === MultiStepAction.Cancel) { - result = { - path: result?.path, - uri: result?.uri, - action: action === MultiStepAction.Back ? 'Back' : 'Cancel', - }; + result = { action, workspaceFolder: undefined, path: undefined, error: 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 new file mode 100644 index 00000000000..52209a5a31d --- /dev/null +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/proposed.createEnvApis.ts @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License + +import { Event, Disposable, WorkspaceFolder } from 'vscode'; +import { EnvironmentTools } from '../../apiTypes'; + +export type CreateEnvironmentUserActions = 'Back' | 'Cancel'; +export type EnvironmentProviderId = string; + +/** + * Options used when creating a Python environment. + */ +export interface CreateEnvironmentOptions { + /** + * Default `true`. If `true`, the environment creation handler is expected to install packages. + */ + installPackages?: boolean; + + /** + * Default `true`. If `true`, the environment creation provider is expected to add the environment to ignore list + * for the source control. + */ + ignoreSourceControl?: boolean; + + /** + * Default `false`. If `true` the creation provider should show back button when showing QuickPick or QuickInput. + */ + showBackButton?: boolean; + + /** + * Default `true`. If `true`, the environment after creation will be selected. + */ + selectEnvironment?: boolean; +} + +/** + * Params passed on `onWillCreateEnvironment` event handler. + */ +export interface EnvironmentWillCreateEvent { + /** + * Options used to create a Python environment. + */ + options: CreateEnvironmentOptions | undefined; +} + +/** + * Params passed on `onDidCreateEnvironment` event handler. + */ +export interface EnvironmentDidCreateEvent extends 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; +} + +/** + * Extensions that want to contribute their own environment creation can do that by registering an object + * that implements this interface. + */ +export interface CreateEnvironmentProvider { + /** + * This API is called when user selects this provider from a QuickPick to select the type of environment + * user wants. This API is expected to show a QuickPick or QuickInput to get the user input and return + * the path to the Python executable in the environment. + * + * @param {CreateEnvironmentOptions} [options] Options used to create a Python environment. + * + * @returns a promise that resolves to the path to the + * Python executable in the environment. Or any action taken by the user, such as back or cancel. + */ + createEnvironment(options?: CreateEnvironmentOptions): Promise; + + /** + * Unique ID for the creation provider, typically : + */ + id: EnvironmentProviderId; + + /** + * Display name for the creation provider. + */ + name: string; + + /** + * Description displayed to the user in the QuickPick to select environment provider. + */ + description: string; + + /** + * Tools used to manage this environment. e.g., ['conda']. In the most to least priority order + * for resolving and working with the environment. + */ + tools: EnvironmentTools[]; +} + +export interface ProposedCreateEnvironmentAPI { + /** + * This API can be used to detect when the environment creation starts for any registered + * provider (including internal providers). This will also receive any options passed in + * or defaults used to create environment. + */ + 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; + + /** + * This API will show a QuickPick to select an environment provider from available list of + * providers. Based on the selection the `createEnvironment` will be called on the provider. + */ + createEnvironment(options?: CreateEnvironmentOptions): Promise; + + /** + * This API should be called to register an environment creation provider. It returns + * a (@link Disposable} which can be used to remove the registration. + */ + registerCreateEnvironmentProvider(provider: CreateEnvironmentProvider): Disposable; +} 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 5bf032f9f65..39cd40afd41 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/condaCreationProvider.ts @@ -5,12 +5,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 { - CreateEnvironmentOptions, - CreateEnvironmentProgress, - CreateEnvironmentProvider, - CreateEnvironmentResult, -} from '../types'; +import { CreateEnvironmentProgress } from '../types'; import { pickWorkspaceFolder } from '../common/workspaceSelection'; import { execObservable } from '../../../common/process/rawProcessApis'; import { createDeferred } from '../../../common/utils/async'; @@ -28,6 +23,11 @@ import { CONDA_ENV_EXISTING_MARKER, } from './condaProgressAndTelemetry'; import { splitLines } from '../../../common/stringUtils'; +import { + CreateEnvironmentOptions, + CreateEnvironmentResult, + CreateEnvironmentProvider, +} from '../proposed.createEnvApis'; function generateCommandArgs(version?: string, options?: CreateEnvironmentOptions): string[] { let addGitIgnore = true; @@ -247,7 +247,7 @@ async function createEnvironment(options?: CreateEnvironmentOptions): Promise(); - private venvOrPipMissingReported = false; - - private venvUpgradingPipReported = false; - - private venvUpgradedPipReported = false; - - private venvFailedReported = false; - - private venvInstallingPackagesReported = false; - - private venvInstallingPackagesFailedReported = false; - - private venvInstalledPackagesReported = false; + private readonly reportActions = new Map string | undefined>([ + [ + VENV_CREATED_MARKER, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.created }); + sendTelemetryEvent(EventName.ENVIRONMENT_CREATED, undefined, { + environmentType: 'venv', + reason: 'created', + }); + return undefined; + }, + ], + [ + VENV_EXISTING_MARKER, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.existing }); + sendTelemetryEvent(EventName.ENVIRONMENT_CREATED, undefined, { + environmentType: 'venv', + reason: 'existing', + }); + return undefined; + }, + ], + [ + INSTALLING_REQUIREMENTS, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.installingPackages }); + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { + environmentType: 'venv', + using: 'requirements.txt', + }); + return undefined; + }, + ], + [ + INSTALLING_PYPROJECT, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.installingPackages }); + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { + environmentType: 'venv', + using: 'pyproject.toml', + }); + return undefined; + }, + ], + [ + PIP_NOT_INSTALLED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { + environmentType: 'venv', + reason: 'noPip', + }); + return PIP_NOT_INSTALLED_MARKER; + }, + ], + [ + DISTUTILS_NOT_INSTALLED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { + environmentType: 'venv', + reason: 'noDistUtils', + }); + return VENV_NOT_INSTALLED_MARKER; + }, + ], + [ + VENV_NOT_INSTALLED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { + environmentType: 'venv', + reason: 'noVenv', + }); + return VENV_NOT_INSTALLED_MARKER; + }, + ], + [ + INSTALL_REQUIREMENTS_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { + environmentType: 'venv', + using: 'requirements.txt', + }); + return INSTALL_REQUIREMENTS_FAILED_MARKER; + }, + ], + [ + INSTALL_PYPROJECT_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { + environmentType: 'venv', + using: 'pyproject.toml', + }); + return INSTALL_PYPROJECT_FAILED_MARKER; + }, + ], + [ + CREATE_VENV_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { + environmentType: 'venv', + reason: 'other', + }); + return CREATE_VENV_FAILED_MARKER; + }, + ], + [ + VENV_ALREADY_EXISTS_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_CREATED, undefined, { + environmentType: 'venv', + reason: 'existing', + }); + return undefined; + }, + ], + [ + INSTALLED_REQUIREMENTS_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLED_PACKAGES, undefined, { + environmentType: 'venv', + using: 'requirements.txt', + }); + return undefined; + }, + ], + [ + INSTALLED_PYPROJECT_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLED_PACKAGES, undefined, { + environmentType: 'venv', + using: 'pyproject.toml', + }); + return undefined; + }, + ], + [ + UPGRADED_PIP_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLED_PACKAGES, undefined, { + environmentType: 'venv', + using: 'pipUpgrade', + }); + return undefined; + }, + ], + [ + UPGRADE_PIP_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { + environmentType: 'venv', + using: 'pipUpgrade', + }); + return UPGRADE_PIP_FAILED_MARKER; + }, + ], + [ + DOWNLOADING_PIP_MARKER, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.downloadingPip }); + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { + environmentType: 'venv', + using: 'pipDownload', + }); + return undefined; + }, + ], + [ + DOWNLOAD_PIP_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { + environmentType: 'venv', + using: 'pipDownload', + }); + return DOWNLOAD_PIP_FAILED_MARKER; + }, + ], + [ + INSTALLING_PIP_MARKER, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.installingPip }); + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { + environmentType: 'venv', + using: 'pipInstall', + }); + return undefined; + }, + ], + [ + INSTALL_PIP_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { + environmentType: 'venv', + using: 'pipInstall', + }); + return INSTALL_PIP_FAILED_MARKER; + }, + ], + [ + CREATING_MICROVENV_MARKER, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.creatingMicrovenv }); + sendTelemetryEvent(EventName.ENVIRONMENT_CREATING, undefined, { + environmentType: 'microvenv', + pythonVersion: undefined, + }); + return undefined; + }, + ], + [ + CREATE_MICROVENV_FAILED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { + environmentType: 'microvenv', + reason: 'other', + }); + return CREATE_MICROVENV_FAILED_MARKER; + }, + ], + [ + CREATE_MICROVENV_FAILED_MARKER2, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { + environmentType: 'microvenv', + reason: 'other', + }); + return CREATE_MICROVENV_FAILED_MARKER2; + }, + ], + [ + MICROVENV_CREATED_MARKER, + (_progress: CreateEnvironmentProgress) => { + sendTelemetryEvent(EventName.ENVIRONMENT_CREATED, undefined, { + environmentType: 'microvenv', + reason: 'created', + }); + return undefined; + }, + ], + [ + UPGRADING_PIP_MARKER, + (progress: CreateEnvironmentProgress) => { + progress.report({ message: CreateEnv.Venv.upgradingPip }); + sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { + environmentType: 'venv', + using: 'pipUpgrade', + }); + return undefined; + }, + ], + ]); private lastError: string | undefined = undefined; constructor(private readonly progress: CreateEnvironmentProgress) {} - public process(output: string): void { - if (!this.venvCreatedReported && output.includes(VENV_CREATED_MARKER)) { - this.venvCreatedReported = true; - this.progress.report({ - message: CreateEnv.Venv.created, - }); - sendTelemetryEvent(EventName.ENVIRONMENT_CREATED, undefined, { - environmentType: 'venv', - reason: 'created', - }); - } else if (!this.venvCreatedReported && output.includes(VENV_EXISTING_MARKER)) { - this.venvCreatedReported = true; - this.progress.report({ - message: CreateEnv.Venv.created, - }); - sendTelemetryEvent(EventName.ENVIRONMENT_CREATED, undefined, { - environmentType: 'venv', - reason: 'existing', - }); - } else if (!this.venvOrPipMissingReported && output.includes(VENV_NOT_INSTALLED_MARKER)) { - this.venvOrPipMissingReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { - environmentType: 'venv', - reason: 'noVenv', - }); - this.lastError = VENV_NOT_INSTALLED_MARKER; - } else if (!this.venvOrPipMissingReported && output.includes(PIP_NOT_INSTALLED_MARKER)) { - this.venvOrPipMissingReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { - environmentType: 'venv', - reason: 'noPip', - }); - this.lastError = PIP_NOT_INSTALLED_MARKER; - } else if (!this.venvUpgradingPipReported && output.includes(UPGRADING_PIP_MARKER)) { - this.venvUpgradingPipReported = true; - this.progress.report({ - message: CreateEnv.Venv.upgradingPip, - }); - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { - environmentType: 'venv', - using: 'pipUpgrade', - }); - } else if (!this.venvFailedReported && output.includes(CREATE_VENV_FAILED_MARKER)) { - this.venvFailedReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_FAILED, undefined, { - environmentType: 'venv', - reason: 'other', - }); - this.lastError = CREATE_VENV_FAILED_MARKER; - } else if (!this.venvInstallingPackagesReported && output.includes(INSTALLING_REQUIREMENTS)) { - this.venvInstallingPackagesReported = true; - this.progress.report({ - message: CreateEnv.Venv.installingPackages, - }); - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { - environmentType: 'venv', - using: 'requirements.txt', - }); - } else if (!this.venvInstallingPackagesReported && output.includes(INSTALLING_PYPROJECT)) { - this.venvInstallingPackagesReported = true; - this.progress.report({ - message: CreateEnv.Venv.installingPackages, - }); - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES, undefined, { - environmentType: 'venv', - using: 'pyproject.toml', - }); - } else if (!this.venvInstallingPackagesFailedReported && output.includes(UPGRADE_PIP_FAILED_MARKER)) { - this.venvInstallingPackagesFailedReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { - environmentType: 'venv', - using: 'pipUpgrade', - }); - this.lastError = UPGRADE_PIP_FAILED_MARKER; - } else if (!this.venvInstallingPackagesFailedReported && output.includes(INSTALL_REQUIREMENTS_FAILED_MARKER)) { - this.venvInstallingPackagesFailedReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { - environmentType: 'venv', - using: 'requirements.txt', - }); - this.lastError = INSTALL_REQUIREMENTS_FAILED_MARKER; - } else if (!this.venvInstallingPackagesFailedReported && output.includes(INSTALL_PYPROJECT_FAILED_MARKER)) { - this.venvInstallingPackagesFailedReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED, undefined, { - environmentType: 'venv', - using: 'pyproject.toml', - }); - this.lastError = INSTALL_PYPROJECT_FAILED_MARKER; - } else if (!this.venvUpgradedPipReported && output.includes(UPGRADED_PIP_MARKER)) { - this.venvUpgradedPipReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLED_PACKAGES, undefined, { - environmentType: 'venv', - using: 'pipUpgrade', - }); - } else if (!this.venvInstalledPackagesReported && output.includes(INSTALLED_REQUIREMENTS_MARKER)) { - this.venvInstalledPackagesReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLED_PACKAGES, undefined, { - environmentType: 'venv', - using: 'requirements.txt', - }); - } else if (!this.venvInstalledPackagesReported && output.includes(INSTALLED_PYPROJECT_MARKER)) { - this.venvInstalledPackagesReported = true; - sendTelemetryEvent(EventName.ENVIRONMENT_INSTALLED_PACKAGES, undefined, { - environmentType: 'venv', - using: 'pyproject.toml', - }); - } - } - public getLastError(): string | undefined { return this.lastError; } + + public process(output: string): void { + const keys: string[] = Array.from(this.reportActions.keys()); + + for (const key of keys) { + if (output.includes(key) && !this.processed.has(key)) { + const action = this.reportActions.get(key); + if (action) { + const err = action(this.progress); + if (err) { + this.lastError = err; + } + } + this.processed.add(key); + } + } + } } 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 4c88deac5b4..7c6505082fb 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvUtils.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/provider/venvUtils.ts @@ -9,7 +9,7 @@ import { CancellationToken, QuickPickItem, RelativePattern, WorkspaceFolder } fr import { CreateEnv } from '../../../common/utils/localize'; import { MultiStepAction, MultiStepNode, showQuickPickWithBack } from '../../../common/vscodeApis/windowApis'; import { findFiles } from '../../../common/vscodeApis/workspaceApis'; -import { traceError, traceInfo, traceVerbose } from '../../../logging'; +import { traceError, traceVerbose } from '../../../logging'; const exclude = '**/{.venv*,.git,.nox,.tox,.conda,site-packages,__pypackages__}/**'; async function getPipRequirementsFiles( @@ -133,10 +133,10 @@ export async function pickPackagesToInstall( hasBuildSystem = tomlHasBuildSystem(toml); if (!hasBuildSystem) { - traceInfo('Create env: Found toml without build system. So we will not use editable install.'); + traceVerbose('Create env: Found toml without build system. So we will not use editable install.'); } if (extras.length === 0) { - traceInfo('Create env: Found toml without optional dependencies.'); + traceVerbose('Create env: Found toml without optional dependencies.'); } } else if (context === MultiStepAction.Back) { // This step is not really used so just go back diff --git a/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts b/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts index 6dbd8adfe1f..611af5bf784 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/creation/types.ts @@ -1,52 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License -import { Progress, Uri } from 'vscode'; +import { Progress } from 'vscode'; export interface CreateEnvironmentProgress extends Progress<{ message?: string; increment?: number }> {} - -export interface CreateEnvironmentOptions { - /** - * Default `true`. If `true`, the environment creation handler is expected to install packages. - */ - installPackages?: boolean; - - /** - * Default `true`. If `true`, the environment creation provider is expected to add the environment to ignore list - * for the source control. - */ - ignoreSourceControl?: boolean; - - /** - * Default `false`. If `true` the creation provider should show back button when showing QuickPick or QuickInput. - */ - showBackButton?: boolean; - - /** - * Default `true`. If `true`, the environment will be selected as the environment to be used for the workspace. - */ - selectEnvironment?: boolean; -} - -export interface CreateEnvironmentResult { - path: string | undefined; - uri: Uri | undefined; - action?: 'Back' | 'Cancel'; -} - -export interface CreateEnvironmentStartedEventArgs { - options: CreateEnvironmentOptions | undefined; -} - -export interface CreateEnvironmentExitedEventArgs { - result: CreateEnvironmentResult | undefined; - error?: unknown; - options: CreateEnvironmentOptions | undefined; -} - -export interface CreateEnvironmentProvider { - createEnvironment(options?: CreateEnvironmentOptions): Promise; - name: string; - description: string; - id: string; -} diff --git a/extensions/positron-python/src/client/pythonEnvironments/info/interpreter.ts b/extensions/positron-python/src/client/pythonEnvironments/info/interpreter.ts index 8925c7a6feb..8fe9bc7d49a 100644 --- a/extensions/positron-python/src/client/pythonEnvironments/info/interpreter.ts +++ b/extensions/positron-python/src/client/pythonEnvironments/info/interpreter.ts @@ -48,7 +48,7 @@ function extractInterpreterInfo(python: string, raw: InterpreterInfoJson): Inter } type Logger = { - info(msg: string): void; + verbose(msg: string): void; error(msg: string): void; }; @@ -85,7 +85,7 @@ export async function getInterpreterInfo( } const json = parse(result.stdout); if (logger) { - logger.info(`Found interpreter for ${argv}`); + logger.verbose(`Found interpreter for ${argv}`); } if (!json) { return undefined; diff --git a/extensions/positron-python/src/client/telemetry/constants.ts b/extensions/positron-python/src/client/telemetry/constants.ts index d30a2683562..159f5690e5c 100644 --- a/extensions/positron-python/src/client/telemetry/constants.ts +++ b/extensions/positron-python/src/client/telemetry/constants.ts @@ -30,6 +30,7 @@ export enum EventName { PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT = 'PYTHON_INTERPRETER_ACTIVATE_ENVIRONMENT_PROMPT', PYTHON_NOT_INSTALLED_PROMPT = 'PYTHON_NOT_INSTALLED_PROMPT', CONDA_INHERIT_ENV_PROMPT = 'CONDA_INHERIT_ENV_PROMPT', + REQUIRE_JUPYTER_PROMPT = 'REQUIRE_JUPYTER_PROMPT', ACTIVATED_CONDA_ENV_LAUNCH = 'ACTIVATED_CONDA_ENV_LAUNCH', ENVFILE_VARIABLE_SUBSTITUTION = 'ENVFILE_VARIABLE_SUBSTITUTION', ENVFILE_WORKSPACE = 'ENVFILE_WORKSPACE', @@ -110,6 +111,7 @@ export enum EventName { ENVIRONMENT_INSTALLING_PACKAGES = 'ENVIRONMENT.INSTALLING_PACKAGES', ENVIRONMENT_INSTALLED_PACKAGES = 'ENVIRONMENT.INSTALLED_PACKAGES', ENVIRONMENT_INSTALLING_PACKAGES_FAILED = 'ENVIRONMENT.INSTALLING_PACKAGES_FAILED', + ENVIRONMENT_BUTTON = 'ENVIRONMENT.BUTTON', 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 973e5530263..5dff3506719 100644 --- a/extensions/positron-python/src/client/telemetry/index.ts +++ b/extensions/positron-python/src/client/telemetry/index.ts @@ -1315,6 +1315,22 @@ export interface IEventNamePropertyMapping { */ selection: 'Allow' | 'Close' | undefined; }; + /** + * Telemetry event sent with details when user attempts to run in interactive window when Jupyter is not installed. + */ + /* __GDPR__ + "conda_inherit_env_prompt" : { + "selection" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "karrtikr" } + } + */ + [EventName.REQUIRE_JUPYTER_PROMPT]: { + /** + * `Yes` When 'Yes' option is selected + * `No` When 'No' option is selected + * `undefined` When 'x' is selected + */ + selection: 'Yes' | 'No' | undefined; + }; /** * Telemetry event sent with details when user clicks the prompt with the following message: * @@ -2014,7 +2030,7 @@ export interface IEventNamePropertyMapping { } */ [EventName.ENVIRONMENT_CREATING]: { - environmentType: 'venv' | 'conda'; + environmentType: 'venv' | 'conda' | 'microvenv'; pythonVersion: string | undefined; }; /** @@ -2027,7 +2043,7 @@ export interface IEventNamePropertyMapping { } */ [EventName.ENVIRONMENT_CREATED]: { - environmentType: 'venv' | 'conda'; + environmentType: 'venv' | 'conda' | 'microvenv'; reason: 'created' | 'existing'; }; /** @@ -2040,8 +2056,8 @@ export interface IEventNamePropertyMapping { } */ [EventName.ENVIRONMENT_FAILED]: { - environmentType: 'venv' | 'conda'; - reason: 'noVenv' | 'noPip' | 'other'; + environmentType: 'venv' | 'conda' | 'microvenv'; + reason: 'noVenv' | 'noPip' | 'noDistUtils' | 'other'; }; /** * Telemetry event sent before installing packages. @@ -2053,8 +2069,8 @@ export interface IEventNamePropertyMapping { } */ [EventName.ENVIRONMENT_INSTALLING_PACKAGES]: { - environmentType: 'venv' | 'conda'; - using: 'requirements.txt' | 'pyproject.toml' | 'environment.yml' | 'pipUpgrade'; + environmentType: 'venv' | 'conda' | 'microvenv'; + using: 'requirements.txt' | 'pyproject.toml' | 'environment.yml' | 'pipUpgrade' | 'pipInstall' | 'pipDownload'; }; /** * Telemetry event sent after installing packages. @@ -2079,9 +2095,16 @@ export interface IEventNamePropertyMapping { } */ [EventName.ENVIRONMENT_INSTALLING_PACKAGES_FAILED]: { - environmentType: 'venv' | 'conda'; - using: 'pipUpgrade' | 'requirements.txt' | 'pyproject.toml' | 'environment.yml'; + environmentType: 'venv' | 'conda' | 'microvenv'; + using: 'pipUpgrade' | 'requirements.txt' | 'pyproject.toml' | 'environment.yml' | 'pipDownload' | 'pipInstall'; }; + /** + * Telemetry event sent if create environment button was used to trigger the command. + */ + /* __GDPR__ + "environment.button" : {"owner": "karthiknadig" } + */ + [EventName.ENVIRONMENT_BUTTON]: never | undefined; /** * 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 9348b0f39d0..905cacc5fbf 100644 --- a/extensions/positron-python/src/client/telemetry/pylance.ts +++ b/extensions/positron-python/src/client/telemetry/pylance.ts @@ -137,6 +137,11 @@ "name" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } } */ +/* __GDPR__ + "language_server/goto_def_inside_string" : { + "resultlength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } +*/ /* __GDPR__ "language_server/import_heuristic" : { "avgcost" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, diff --git a/extensions/positron-python/src/client/tensorBoard/tensorBoardSession.ts b/extensions/positron-python/src/client/tensorBoard/tensorBoardSession.ts index 5d5b9cd1bb3..1d24e8c313f 100644 --- a/extensions/positron-python/src/client/tensorBoard/tensorBoardSession.ts +++ b/extensions/positron-python/src/client/tensorBoard/tensorBoardSession.ts @@ -47,7 +47,7 @@ import { ImportTracker } from '../telemetry/importTracker'; import { TensorBoardPromptSelection, TensorBoardSessionStartResult } from './constants'; import { IMultiStepInputFactory } from '../common/utils/multiStepInput'; import { ModuleInstallFlags } from '../common/installer/types'; -import { traceError, traceInfo } from '../logging'; +import { traceError, traceVerbose } from '../logging'; enum Messages { JumpToSource = 'jump_to_source', @@ -190,7 +190,7 @@ export class TensorBoardSession { // any of their open documents, also try to install the torch-tb-plugin // package, but don't block if installing that fails. private async ensurePrerequisitesAreInstalled() { - traceInfo('Ensuring TensorBoard package is installed into active interpreter'); + traceVerbose('Ensuring TensorBoard package is installed into active interpreter'); const interpreter = (await this.interpreterService.getActiveInterpreter()) || (await this.commandManager.executeCommand('python.setInterpreter')); @@ -344,7 +344,7 @@ export class TensorBoardSession { const settings = this.configurationService.getSettings(); const settingValue = settings.tensorBoard?.logDirectory; if (settingValue) { - traceInfo(`Using log directory resolved by python.tensorBoard.logDirectory setting: ${settingValue}`); + traceVerbose(`Using log directory resolved by python.tensorBoard.logDirectory setting: ${settingValue}`); return settingValue; } // No log directory in settings. Ask the user which directory to use @@ -406,7 +406,7 @@ export class TensorBoardSession { const result = await this.applicationShell.withProgress( progressOptions, (_progress: Progress, token: CancellationToken) => { - traceInfo(`Starting TensorBoard with log directory ${logDir}...`); + traceVerbose(`Starting TensorBoard with log directory ${logDir}...`); const spawnTensorBoard = this.waitForTensorBoardStart(observable); const userCancellation = createPromiseFromCancellation({ @@ -421,7 +421,7 @@ export class TensorBoardSession { switch (result) { case 'canceled': - traceInfo('Canceled starting TensorBoard session.'); + traceVerbose('Canceled starting TensorBoard session.'); sendTelemetryEvent( EventName.TENSORBOARD_SESSION_DAEMON_STARTUP_DURATION, sessionStartStopwatch.elapsedTime, @@ -468,7 +468,7 @@ export class TensorBoardSession { this.url = match[1]; urlThatTensorBoardIsRunningAt.resolve('success'); } - traceInfo(output.out); + traceVerbose(output.out); } else if (output.source === 'stderr') { traceError(output.out); } @@ -482,7 +482,7 @@ export class TensorBoardSession { } private async showPanel() { - traceInfo('Showing TensorBoard panel'); + traceVerbose('Showing TensorBoard panel'); const panel = this.webviewPanel || (await this.createPanel()); panel.reveal(); this._active = true; diff --git a/extensions/positron-python/src/client/tensorBoard/tensorBoardSessionProvider.ts b/extensions/positron-python/src/client/tensorBoard/tensorBoardSessionProvider.ts index f689dab6a6b..53878bd543c 100644 --- a/extensions/positron-python/src/client/tensorBoard/tensorBoardSessionProvider.ts +++ b/extensions/positron-python/src/client/tensorBoard/tensorBoardSessionProvider.ts @@ -17,7 +17,7 @@ import { } from '../common/types'; import { IMultiStepInputFactory } from '../common/utils/multiStepInput'; import { IInterpreterService } from '../interpreter/contracts'; -import { traceError, traceInfo } from '../logging'; +import { traceError, traceVerbose } from '../logging'; import { sendTelemetryEvent } from '../telemetry'; import { EventName } from '../telemetry/constants'; import { TensorBoardEntrypoint, TensorBoardEntrypointTrigger } from './constants'; @@ -94,7 +94,7 @@ export class TensorBoardSessionProvider implements IExtensionSingleActivationSer } private async createNewSession(): Promise { - traceInfo('Starting new TensorBoard session...'); + traceVerbose('Starting new TensorBoard session...'); try { const newSession = new TensorBoardSession( this.installer, diff --git a/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts b/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts index 6b4e5947b3d..ed671f2846a 100644 --- a/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts +++ b/extensions/positron-python/src/client/terminals/codeExecution/codeExecutionManager.ts @@ -91,11 +91,14 @@ export class CodeExecutionManager implements ICodeExecutionManager { sendTelemetryEvent(EventName.EXECUTION_CODE, undefined, { scope: 'file', trigger }); const codeExecutionHelper = this.serviceContainer.get(ICodeExecutionHelper); file = file instanceof Uri ? file : undefined; - const fileToExecute = file ? file : await codeExecutionHelper.getFileToExecute(); + let fileToExecute = file ? file : await codeExecutionHelper.getFileToExecute(); if (!fileToExecute) { return; } - await codeExecutionHelper.saveFileIfDirty(fileToExecute); + const fileAfterSave = await codeExecutionHelper.saveFileIfDirty(fileToExecute); + if (fileAfterSave) { + fileToExecute = fileAfterSave; + } try { const contents = await this.fileSystem.readFile(fileToExecute.fsPath); diff --git a/extensions/positron-python/src/client/terminals/codeExecution/helper.ts b/extensions/positron-python/src/client/terminals/codeExecution/helper.ts index d4f205883ce..eee7d91a0db 100644 --- a/extensions/positron-python/src/client/terminals/codeExecution/helper.ts +++ b/extensions/positron-python/src/client/terminals/codeExecution/helper.ts @@ -5,7 +5,7 @@ import '../../common/extensions'; import { inject, injectable } from 'inversify'; import { l10n, Position, Range, TextEditor, Uri } from 'vscode'; -import { IApplicationShell, IDocumentManager } from '../../common/application/types'; +import { IApplicationShell, ICommandManager, IDocumentManager } from '../../common/application/types'; import { PYTHON_LANGUAGE } from '../../common/constants'; import * as internalScripts from '../../common/process/internal/scripts'; import { IProcessServiceFactory } from '../../common/process/types'; @@ -14,6 +14,7 @@ import { IInterpreterService } from '../../interpreter/contracts'; import { IServiceContainer } from '../../ioc/types'; import { ICodeExecutionHelper } from '../types'; import { traceError } from '../../logging'; +import { Resource } from '../../common/types'; @injectable() export class CodeExecutionHelper implements ICodeExecutionHelper { @@ -25,7 +26,7 @@ export class CodeExecutionHelper implements ICodeExecutionHelper { private readonly interpreterService: IInterpreterService; - constructor(@inject(IServiceContainer) serviceContainer: IServiceContainer) { + constructor(@inject(IServiceContainer) private readonly serviceContainer: IServiceContainer) { this.documentManager = serviceContainer.get(IDocumentManager); this.applicationShell = serviceContainer.get(IApplicationShell); this.processServiceFactory = serviceContainer.get(IProcessServiceFactory); @@ -119,11 +120,17 @@ export class CodeExecutionHelper implements ICodeExecutionHelper { return code; } - public async saveFileIfDirty(file: Uri): Promise { + public async saveFileIfDirty(file: Uri): Promise { const docs = this.documentManager.textDocuments.filter((d) => d.uri.path === file.path); if (docs.length === 1 && docs[0].isDirty) { - await docs[0].save(); + const deferred = createDeferred(); + this.documentManager.onDidSaveTextDocument((e) => deferred.resolve(e.uri)); + const commandManager = this.serviceContainer.get(ICommandManager); + await commandManager.executeCommand('workbench.action.files.save', file); + const savedFileUri = await deferred.promise; + return savedFileUri; } + return undefined; } } diff --git a/extensions/positron-python/src/client/terminals/types.ts b/extensions/positron-python/src/client/terminals/types.ts index ee96e72b07c..cf31f4ef1dd 100644 --- a/extensions/positron-python/src/client/terminals/types.ts +++ b/extensions/positron-python/src/client/terminals/types.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Event, Terminal, TextEditor, Uri } from 'vscode'; -import { IDisposable } from '../common/types'; +import { IDisposable, Resource } from '../common/types'; export const ICodeExecutionService = Symbol('ICodeExecutionService'); @@ -17,7 +17,7 @@ export const ICodeExecutionHelper = Symbol('ICodeExecutionHelper'); export interface ICodeExecutionHelper { normalizeLines(code: string): Promise; getFileToExecute(): Promise; - saveFileIfDirty(file: Uri): Promise; + saveFileIfDirty(file: Uri): Promise; getSelectedTextToExecute(textEditor: TextEditor): Promise; } diff --git a/extensions/positron-python/src/client/testing/common/testConfigurationManager.ts b/extensions/positron-python/src/client/testing/common/testConfigurationManager.ts index c2b050cb524..be3f0109da0 100644 --- a/extensions/positron-python/src/client/testing/common/testConfigurationManager.ts +++ b/extensions/positron-python/src/client/testing/common/testConfigurationManager.ts @@ -5,12 +5,12 @@ import { IFileSystem } from '../../common/platform/types'; import { IInstaller } from '../../common/types'; import { createDeferred } from '../../common/utils/async'; import { IServiceContainer } from '../../ioc/types'; -import { traceInfo } from '../../logging'; +import { traceVerbose } from '../../logging'; import { UNIT_TEST_PRODUCTS } from './constants'; import { ITestConfigSettingsService, ITestConfigurationManager, UnitTestProduct } from './types'; function handleCancelled(): void { - traceInfo('testing configuration (in UI) cancelled'); + traceVerbose('testing configuration (in UI) cancelled'); throw Error('cancelled'); } diff --git a/extensions/positron-python/src/client/testing/constants.ts b/extensions/positron-python/src/client/testing/constants.ts deleted file mode 100644 index f8c4bb74949..00000000000 --- a/extensions/positron-python/src/client/testing/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -export const TEST_OUTPUT_CHANNEL = 'TEST_OUTPUT_CHANNEL'; 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 48c0b81972a..6849f0f8969 100644 --- a/extensions/positron-python/src/client/testing/testController/common/server.ts +++ b/extensions/positron-python/src/client/testing/testController/common/server.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as http from 'http'; import * as net from 'net'; import * as crypto from 'crypto'; import { Disposable, Event, EventEmitter } from 'vscode'; @@ -14,53 +13,61 @@ import { traceLog } from '../../../logging'; 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'; export class PythonTestServer implements ITestServer, Disposable { private _onDataReceived: EventEmitter = new EventEmitter(); - private uuids: Map; + private uuids: Array = []; - private server: http.Server; + private server: net.Server; private ready: Promise; constructor(private executionFactory: IPythonExecutionFactory, private debugLauncher: ITestDebugLauncher) { - this.uuids = new Map(); - - const requestListener: http.RequestListener = async (request, response) => { - const buffers = []; - - try { - for await (const chunk of request) { - buffers.push(chunk); - } - - const data = Buffer.concat(buffers).toString(); - // grab the uuid from the header - const indexRequestuuid = request.rawHeaders.indexOf('Request-uuid'); - const uuid = request.rawHeaders[indexRequestuuid + 1]; - response.end(); - - JSON.parse(data); - // Check if the uuid we received exists in the list of active ones. - // If yes, process the response, if not, ignore it. - const cwd = this.uuids.get(uuid); - if (cwd) { - this._onDataReceived.fire({ cwd, data }); - this.uuids.delete(uuid); + this.server = net.createServer((socket: net.Socket) => { + socket.on('data', (data: Buffer) => { + try { + let rawData: string = data.toString(); + + while (rawData.length > 0) { + const rpcHeaders = jsonRPCHeaders(rawData); + const uuid = rpcHeaders.headers.get(JSONRPC_UUID_HEADER); + rawData = rpcHeaders.remainingRawData; + if (uuid && this.uuids.includes(uuid)) { + const rpcContent = jsonRPCContent(rpcHeaders.headers, rawData); + rawData = rpcContent.remainingRawData; + this._onDataReceived.fire({ uuid, data: rpcContent.extractedJSON }); + this.uuids = this.uuids.filter((u) => u !== uuid); + } else { + traceLog(`Error processing test server request: uuid not found`); + this._onDataReceived.fire({ uuid: '', data: '' }); + return; + } + } + } catch (ex) { + traceLog(`Error processing test server request: ${ex} observe`); + this._onDataReceived.fire({ uuid: '', data: '' }); } - } catch (ex) { - traceLog(`Error processing test server request: ${ex} observe`); - this._onDataReceived.fire({ cwd: '', data: '' }); - } - }; - - this.server = http.createServer(requestListener); + }); + }); this.ready = new Promise((resolve, _reject) => { this.server.listen(undefined, 'localhost', () => { resolve(); }); }); + this.server.on('error', (ex) => { + traceLog(`Error starting test server: ${ex}`); + }); + this.server.on('close', () => { + traceLog('Test server closed.'); + }); + this.server.on('listening', () => { + traceLog('Test server listening.'); + }); + this.server.on('connection', () => { + traceLog('Test server connected to a client.'); + }); } public serverReady(): Promise { @@ -71,9 +78,9 @@ export class PythonTestServer implements ITestServer, Disposable { return (this.server.address() as net.AddressInfo).port; } - public createUUID(cwd: string): string { + public createUUID(): string { const uuid = crypto.randomUUID(); - this.uuids.set(uuid, cwd); + this.uuids.push(uuid); return uuid; } @@ -87,11 +94,12 @@ export class PythonTestServer implements ITestServer, Disposable { } async sendCommand(options: TestCommandOptions): Promise { - const uuid = this.createUUID(options.cwd); + const { uuid } = options; const spawnOptions: SpawnOptions = { token: options.token, cwd: options.cwd, throwOnStdErr: true, + outputChannel: options.outChannel, }; // Create the Python environment in which to execute the command. @@ -140,9 +148,9 @@ export class PythonTestServer implements ITestServer, Disposable { await execService.exec(args, spawnOptions); } } catch (ex) { - this.uuids.delete(uuid); + this.uuids = this.uuids.filter((u) => u !== uuid); this._onDataReceived.fire({ - cwd: options.cwd, + uuid, data: JSON.stringify({ status: 'error', errors: [(ex as Error).message], 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 e9eebb4c44d..52c6c787040 100644 --- a/extensions/positron-python/src/client/testing/testController/common/types.ts +++ b/extensions/positron-python/src/client/testing/testController/common/types.ts @@ -12,8 +12,8 @@ import { Uri, WorkspaceFolder, } from 'vscode'; -// ** import { IPythonExecutionFactory } from '../../../common/process/types'; import { TestDiscoveryOptions } from '../../common/types'; +import { IPythonExecutionFactory } from '../../../common/process/types'; export type TestRunInstanceOptions = TestRunOptions & { exclude?: readonly TestItem[]; @@ -128,7 +128,7 @@ export type RawDiscoveredTests = { // New test discovery adapter types export type DataReceivedEvent = { - cwd: string; + uuid: string; data: string; }; @@ -146,6 +146,7 @@ export type TestCommandOptions = { workspaceFolder: Uri; cwd: string; command: TestDiscoveryCommand | TestExecutionCommand; + uuid: string; token?: CancellationToken; outChannel?: OutputChannel; debugBool?: boolean; @@ -178,21 +179,21 @@ export interface ITestServer { } export interface ITestDiscoveryAdapter { - // ** Uncomment second line and comment out first line to use the new discovery method. + // ** first line old method signature, second line new method signature discoverTests(uri: Uri): Promise; - // discoverTests(uri: Uri, executionFactory: IPythonExecutionFactory): Promise; + discoverTests(uri: Uri, executionFactory: IPythonExecutionFactory): Promise; } // interface for execution/runner adapter export interface ITestExecutionAdapter { - // ** Uncomment second line and comment out first line to use the new execution method. + // ** first line old method signature, second line new method signature runTests(uri: Uri, testIds: string[], debugBool?: boolean): Promise; - // runTests( - // uri: Uri, - // testIds: string[], - // debugBool?: boolean, - // executionFactory?: IPythonExecutionFactory, - // ): Promise; + runTests( + uri: Uri, + testIds: string[], + debugBool?: boolean, + executionFactory?: IPythonExecutionFactory, + ): Promise; } // Same types as in pythonFiles/unittestadapter/utils.py diff --git a/extensions/positron-python/src/client/testing/testController/common/utils.ts b/extensions/positron-python/src/client/testing/testController/common/utils.ts index 13fc76a3719..e0bad383d69 100644 --- a/extensions/positron-python/src/client/testing/testController/common/utils.ts +++ b/extensions/positron-python/src/client/testing/testController/common/utils.ts @@ -5,3 +5,48 @@ export function fixLogLines(content: string): string { const lines = content.split(/\r?\n/g); return `${lines.join('\r\n')}\r\n`; } +export interface IJSONRPCContent { + extractedJSON: string; + remainingRawData: string; +} + +export interface IJSONRPCHeaders { + headers: Map; + remainingRawData: string; +} + +export const JSONRPC_UUID_HEADER = 'Request-uuid'; +export const JSONRPC_CONTENT_LENGTH_HEADER = 'Content-Length'; +export const JSONRPC_CONTENT_TYPE_HEADER = 'Content-Type'; + +export function jsonRPCHeaders(rawData: string): IJSONRPCHeaders { + const lines = rawData.split('\n'); + let remainingRawData = ''; + const headerMap = new Map(); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (line === '') { + remainingRawData = lines.slice(i + 1).join('\n'); + break; + } + const [key, value] = line.split(':'); + if ([JSONRPC_UUID_HEADER, JSONRPC_CONTENT_LENGTH_HEADER, JSONRPC_CONTENT_TYPE_HEADER].includes(key)) { + headerMap.set(key.trim(), value.trim()); + } + } + + return { + headers: headerMap, + remainingRawData, + }; +} + +export function jsonRPCContent(headers: Map, rawData: string): IJSONRPCContent { + const length = parseInt(headers.get('Content-Length') ?? '0', 10); + const data = rawData.slice(0, length); + const remainingRawData = rawData.slice(length); + return { + extractedJSON: data, + remainingRawData, + }; +} diff --git a/extensions/positron-python/src/client/testing/testController/controller.ts b/extensions/positron-python/src/client/testing/testController/controller.ts index b2be2d9c305..fb176a30af8 100644 --- a/extensions/positron-python/src/client/testing/testController/controller.ts +++ b/extensions/positron-python/src/client/testing/testController/controller.ts @@ -20,7 +20,7 @@ import { IExtensionSingleActivationService } from '../../activation/types'; import { ICommandManager, IWorkspaceService } from '../../common/application/types'; import * as constants from '../../common/constants'; import { IPythonExecutionFactory } from '../../common/process/types'; -import { IConfigurationService, IDisposableRegistry, Resource } from '../../common/types'; +import { IConfigurationService, IDisposableRegistry, ITestOutputChannel, Resource } from '../../common/types'; import { DelayedTrigger, IDelayedTrigger } from '../../common/utils/delayTrigger'; import { noop } from '../../common/utils/misc'; import { IInterpreterService } from '../../interpreter/contracts'; @@ -92,6 +92,7 @@ export class PythonTestController implements ITestController, IExtensionSingleAc @inject(ICommandManager) private readonly commandManager: ICommandManager, @inject(IPythonExecutionFactory) private readonly pythonExecFactory: IPythonExecutionFactory, @inject(ITestDebugLauncher) private readonly debugLauncher: ITestDebugLauncher, + @inject(ITestOutputChannel) private readonly testOutputChannel: ITestOutputChannel, ) { this.refreshCancellation = new CancellationTokenSource(); @@ -158,12 +159,28 @@ export class PythonTestController implements ITestController, IExtensionSingleAc let executionAdapter: ITestExecutionAdapter; let testProvider: TestProvider; if (settings.testing.unittestEnabled) { - discoveryAdapter = new UnittestTestDiscoveryAdapter(this.pythonTestServer, this.configSettings); - executionAdapter = new UnittestTestExecutionAdapter(this.pythonTestServer, this.configSettings); + discoveryAdapter = new UnittestTestDiscoveryAdapter( + this.pythonTestServer, + this.configSettings, + this.testOutputChannel, + ); + executionAdapter = new UnittestTestExecutionAdapter( + this.pythonTestServer, + this.configSettings, + this.testOutputChannel, + ); testProvider = UNITTEST_PROVIDER; } else { - discoveryAdapter = new PytestTestDiscoveryAdapter(this.pythonTestServer, this.configSettings); - executionAdapter = new PytestTestExecutionAdapter(this.pythonTestServer, this.configSettings); + discoveryAdapter = new PytestTestDiscoveryAdapter( + this.pythonTestServer, + this.configSettings, + this.testOutputChannel, + ); + executionAdapter = new PytestTestExecutionAdapter( + this.pythonTestServer, + this.configSettings, + this.testOutputChannel, + ); testProvider = PYTEST_PROVIDER; } @@ -224,39 +241,46 @@ export class PythonTestController implements ITestController, IExtensionSingleAc if (uri) { const settings = this.configSettings.getSettings(uri); traceVerbose(`Testing: Refreshing test data for ${uri.fsPath}`); + const rewriteTestingEnabled = process.env.ENABLE_PYTHON_TESTING_REWRITE; if (settings.testing.pytestEnabled) { // Ensure we send test telemetry if it gets disabled again this.sendTestDisabledTelemetry = true; - // ** uncomment ~231 - 241 to NEW new test discovery mechanism - // const workspace = this.workspaceService.getWorkspaceFolder(uri); - // traceVerbose(`Discover tests for workspace name: ${workspace?.name} - uri: ${uri.fsPath}`); - // const testAdapter = - // this.testAdapters.get(uri) || (this.testAdapters.values().next().value as WorkspaceTestAdapter); - // testAdapter.discoverTests( - // this.testController, - // this.refreshCancellation.token, - // this.testAdapters.size > 1, - // this.workspaceService.workspaceFile?.fsPath, - // this.pythonExecFactory, - // ); - // uncomment ~243 to use OLD test discovery mechanism - await this.pytest.refreshTestData(this.testController, uri, this.refreshCancellation.token); + if (rewriteTestingEnabled) { + // ** rewriteTestingEnabled set to true to use NEW test discovery mechanism + const workspace = this.workspaceService.getWorkspaceFolder(uri); + traceVerbose(`Discover tests for workspace name: ${workspace?.name} - uri: ${uri.fsPath}`); + const testAdapter = + this.testAdapters.get(uri) || (this.testAdapters.values().next().value as WorkspaceTestAdapter); + testAdapter.discoverTests( + this.testController, + this.refreshCancellation.token, + this.testAdapters.size > 1, + this.workspaceService.workspaceFile?.fsPath, + this.pythonExecFactory, + ); + } else { + // else use OLD test discovery mechanism + await this.pytest.refreshTestData(this.testController, uri, this.refreshCancellation.token); + } } else if (settings.testing.unittestEnabled) { // ** Ensure we send test telemetry if it gets disabled again this.sendTestDisabledTelemetry = true; - // uncomment ~248 - 258 to NEW new test discovery mechanism - // const workspace = this.workspaceService.getWorkspaceFolder(uri); - // traceVerbose(`Discover tests for workspace name: ${workspace?.name} - uri: ${uri.fsPath}`); - // const testAdapter = - // this.testAdapters.get(uri) || (this.testAdapters.values().next().value as WorkspaceTestAdapter); - // testAdapter.discoverTests( - // this.testController, - // this.refreshCancellation.token, - // this.testAdapters.size > 1, - // this.workspaceService.workspaceFile?.fsPath, - // ); - // uncomment ~260 to use OLD test discovery mechanism - await this.unittest.refreshTestData(this.testController, uri, this.refreshCancellation.token); + if (rewriteTestingEnabled) { + // ** rewriteTestingEnabled set to true to use NEW test discovery mechanism + const workspace = this.workspaceService.getWorkspaceFolder(uri); + traceVerbose(`Discover tests for workspace name: ${workspace?.name} - uri: ${uri.fsPath}`); + const testAdapter = + this.testAdapters.get(uri) || (this.testAdapters.values().next().value as WorkspaceTestAdapter); + testAdapter.discoverTests( + this.testController, + this.refreshCancellation.token, + this.testAdapters.size > 1, + this.workspaceService.workspaceFile?.fsPath, + ); + } else { + // else use OLD test discovery mechanism + await this.unittest.refreshTestData(this.testController, uri, this.refreshCancellation.token); + } } else { if (this.sendTestDisabledTelemetry) { this.sendTestDisabledTelemetry = false; @@ -367,25 +391,26 @@ export class PythonTestController implements ITestController, IExtensionSingleAc const settings = this.configSettings.getSettings(workspace.uri); if (testItems.length > 0) { + const rewriteTestingEnabled = process.env.ENABLE_PYTHON_TESTING_REWRITE; if (settings.testing.pytestEnabled) { sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { tool: 'pytest', debugging: request.profile?.kind === TestRunProfileKind.Debug, }); - // ** new execution runner/adapter - // const testAdapter = - // this.testAdapters.get(workspace.uri) || - // (this.testAdapters.values().next().value as WorkspaceTestAdapter); - // return testAdapter.executeTests( - // this.testController, - // runInstance, - // testItems, - // token, - // request.profile?.kind === TestRunProfileKind.Debug, - // this.pythonExecFactory, - // ); - - // below is old way of running pytest execution + // ** rewriteTestingEnabled set to true to use NEW test discovery mechanism + if (rewriteTestingEnabled) { + const testAdapter = + this.testAdapters.get(workspace.uri) || + (this.testAdapters.values().next().value as WorkspaceTestAdapter); + return testAdapter.executeTests( + this.testController, + runInstance, + testItems, + token, + request.profile?.kind === TestRunProfileKind.Debug, + this.pythonExecFactory, + ); + } return this.pytest.runTests( { includes: testItems, @@ -398,23 +423,23 @@ export class PythonTestController implements ITestController, IExtensionSingleAc ); } if (settings.testing.unittestEnabled) { - // potentially squeeze in the new execution way here? sendTelemetryEvent(EventName.UNITTEST_RUN, undefined, { tool: 'unittest', debugging: request.profile?.kind === TestRunProfileKind.Debug, }); - // new execution runner/adapter - // const testAdapter = - // this.testAdapters.get(workspace.uri) || - // (this.testAdapters.values().next().value as WorkspaceTestAdapter); - // return testAdapter.executeTests( - // this.testController, - // runInstance, - // testItems, - // token, - // request.profile?.kind === TestRunProfileKind.Debug, - // ); - + // ** rewriteTestingEnabled set to true to use NEW test discovery mechanism + if (rewriteTestingEnabled) { + const testAdapter = + this.testAdapters.get(workspace.uri) || + (this.testAdapters.values().next().value as WorkspaceTestAdapter); + return testAdapter.executeTests( + this.testController, + runInstance, + testItems, + token, + request.profile?.kind === TestRunProfileKind.Debug, + ); + } // below is old way of running unittest execution return this.unittest.runTests( { 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 e2108b87284..792826f4c3a 100644 --- a/extensions/positron-python/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/pytest/pytestDiscoveryAdapter.ts @@ -7,7 +7,7 @@ import { IPythonExecutionFactory, SpawnOptions, } from '../../../common/process/types'; -import { IConfigurationService } from '../../../common/types'; +import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { createDeferred, Deferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { traceVerbose } from '../../../logging'; @@ -17,77 +17,74 @@ import { DataReceivedEvent, DiscoveredTestPayload, ITestDiscoveryAdapter, ITestS * Wrapper class for unittest test discovery. This is where we call `runTestCommand`. #this seems incorrectly copied */ export class PytestTestDiscoveryAdapter implements ITestDiscoveryAdapter { - private deferred: Deferred | undefined; + private promiseMap: Map> = new Map(); - private cwd: string | undefined; + private deferred: Deferred | undefined; - constructor(public testServer: ITestServer, public configSettings: IConfigurationService) { + constructor( + public testServer: ITestServer, + public configSettings: IConfigurationService, + private readonly outputChannel: ITestOutputChannel, + ) { testServer.onDataReceived(this.onDataReceivedHandler, this); } - public onDataReceivedHandler({ cwd, data }: DataReceivedEvent): void { - if (this.deferred && cwd === this.cwd) { - const testData: DiscoveredTestPayload = JSON.parse(data); - - this.deferred.resolve(testData); - this.deferred = undefined; + public onDataReceivedHandler({ uuid, data }: DataReceivedEvent): void { + const deferred = this.promiseMap.get(uuid); + if (deferred) { + deferred.resolve(JSON.parse(data)); + this.promiseMap.delete(uuid); } } - // ** Old version of discover tests. - discoverTests(uri: Uri): Promise { + discoverTests(uri: Uri, executionFactory?: IPythonExecutionFactory): Promise { + if (executionFactory !== undefined) { + // ** new version of discover tests. + const settings = this.configSettings.getSettings(uri); + const { pytestArgs } = settings.testing; + traceVerbose(pytestArgs); + return this.runPytestDiscovery(uri, executionFactory); + } + // if executionFactory is undefined, we are using the old method signature of discover tests. traceVerbose(uri); this.deferred = createDeferred(); return this.deferred.promise; } - // Uncomment this version of the function discoverTests to use the new discovery method. - // public async discoverTests(uri: Uri, executionFactory: IPythonExecutionFactory): Promise { - // const settings = this.configSettings.getSettings(uri); - // const { pytestArgs } = settings.testing; - // traceVerbose(pytestArgs); - - // this.cwd = uri.fsPath; - // return this.runPytestDiscovery(uri, executionFactory); - // } async runPytestDiscovery(uri: Uri, executionFactory: IPythonExecutionFactory): Promise { - if (!this.deferred) { - this.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 deferred = createDeferred(); + const relativePathToPytest = 'pythonFiles'; + const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); + const uuid = this.testServer.createUUID(uri.fsPath); + this.promiseMap.set(uuid, deferred); + const settings = this.configSettings.getSettings(uri); + const { pytestArgs } = settings.testing; - const pythonPathParts: string[] = process.env.PYTHONPATH?.split(path.delimiter) ?? []; - const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); + const pythonPathParts: string[] = process.env.PYTHONPATH?.split(path.delimiter) ?? []; + const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); - const spawnOptions: SpawnOptions = { - cwd: uri.fsPath, - throwOnStdErr: true, - extraVariables: { - PYTHONPATH: pythonPathCommand, - TEST_UUID: uuid.toString(), - TEST_PORT: this.testServer.getPort().toString(), - }, - }; + const spawnOptions: SpawnOptions = { + cwd: uri.fsPath, + throwOnStdErr: true, + extraVariables: { + PYTHONPATH: pythonPathCommand, + TEST_UUID: uuid.toString(), + TEST_PORT: this.testServer.getPort().toString(), + }, + outputChannel: this.outputChannel, + }; - // Create the Python environment in which to execute the command. - const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { - allowEnvironmentFetchExceptions: false, - resource: uri, - }; - const execService = await executionFactory.createActivatedEnvironment(creationOptions); - - try { - execService.exec( - ['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs), - spawnOptions, - ); - } catch (ex) { - console.error(ex); - } - } - return this.deferred.promise; + // Create the Python environment in which to execute the command. + const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { + allowEnvironmentFetchExceptions: false, + resource: uri, + }; + const execService = await executionFactory.createActivatedEnvironment(creationOptions); + execService + .exec(['-m', 'pytest', '-p', 'vscode_pytest', '--collect-only'].concat(pytestArgs), spawnOptions) + .catch((ex) => { + deferred.reject(ex as Error); + }); + return deferred.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 eaabb57691d..d2cbd3151e6 100644 --- a/extensions/positron-python/src/client/testing/testController/pytest/pytestExecutionAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/pytest/pytestExecutionAdapter.ts @@ -2,96 +2,125 @@ // Licensed under the MIT License. import { Uri } from 'vscode'; -import { IConfigurationService } from '../../../common/types'; +import * as path from 'path'; +import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { createDeferred, Deferred } from '../../../common/utils/async'; import { traceVerbose } from '../../../logging'; import { DataReceivedEvent, ExecutionTestPayload, ITestExecutionAdapter, ITestServer } from '../common/types'; +import { + ExecutionFactoryCreateWithEnvironmentOptions, + IPythonExecutionFactory, + SpawnOptions, +} from '../../../common/process/types'; +import { EXTENSION_ROOT_DIR } from '../../../constants'; +import { removePositionalFoldersAndFiles } from './arguments'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).EXTENSION_ROOT_DIR = EXTENSION_ROOT_DIR; /** * Wrapper Class for pytest test execution. This is where we call `runTestCommand`? */ export class PytestTestExecutionAdapter implements ITestExecutionAdapter { - private deferred: Deferred | undefined; + private promiseMap: Map> = new Map(); - private cwd: string | undefined; + private deferred: Deferred | undefined; - constructor(public testServer: ITestServer, public configSettings: IConfigurationService) { + constructor( + public testServer: ITestServer, + public configSettings: IConfigurationService, + private readonly outputChannel: ITestOutputChannel, + ) { testServer.onDataReceived(this.onDataReceivedHandler, this); } - public onDataReceivedHandler({ cwd, data }: DataReceivedEvent): void { - if (this.deferred && cwd === this.cwd) { - const testData: ExecutionTestPayload = JSON.parse(data); - - this.deferred.resolve(testData); - this.deferred = undefined; + public onDataReceivedHandler({ uuid, data }: DataReceivedEvent): void { + const deferred = this.promiseMap.get(uuid); + if (deferred) { + deferred.resolve(JSON.parse(data)); + this.promiseMap.delete(uuid); } } - // ** Old version of discover tests. - async runTests(uri: Uri, testIds: string[], debugBool?: boolean): Promise { + async runTests( + uri: Uri, + testIds: string[], + debugBool?: boolean, + executionFactory?: IPythonExecutionFactory, + ): Promise { traceVerbose(uri, testIds, debugBool); + if (executionFactory !== undefined) { + // ** new version of run tests. + return this.runTestsNew(uri, testIds, debugBool, executionFactory); + } + // if executionFactory is undefined, we are using the old method signature of run tests. + this.outputChannel.appendLine('Running tests.'); this.deferred = createDeferred(); return this.deferred.promise; } - // public async runTests( - // uri: Uri, - // testIds: string[], - // debugBool?: boolean, - // executionFactory?: IPythonExecutionFactory, - // ): Promise { - // if (!this.deferred) { - // this.deferred = createDeferred(); - // const relativePathToPytest = 'pythonFiles'; - // const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); - // this.configSettings.isTestExecution(); - // const uuid = this.testServer.createUUID(uri.fsPath); - // const settings = this.configSettings.getSettings(uri); - // const { pytestArgs } = settings.testing; - - // const pythonPathParts: string[] = process.env.PYTHONPATH?.split(path.delimiter) ?? []; - // const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); - - // const spawnOptions: SpawnOptions = { - // cwd: uri.fsPath, - // throwOnStdErr: true, - // extraVariables: { - // PYTHONPATH: pythonPathCommand, - // TEST_UUID: uuid.toString(), - // TEST_PORT: this.testServer.getPort().toString(), - // }, - // }; - - // // Create the Python environment in which to execute the command. - // const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { - // allowEnvironmentFetchExceptions: false, - // resource: uri, - // }; - // // need to check what will happen in the exec service is NOT defined and is null - // const execService = await executionFactory?.createActivatedEnvironment(creationOptions); - - // const testIdsString = testIds.join(' '); - // console.debug('what to do with debug bool?', debugBool); - // try { - // execService?.exec( - // ['-m', 'pytest', '-p', 'vscode_pytest', testIdsString].concat(pytestArgs), - // spawnOptions, - // ); - // } catch (ex) { - // console.error(ex); - // } - // } - // return this.deferred.promise; - // } - // } - - // function buildExecutionCommand(args: string[]): TestExecutionCommand { - // const executionScript = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'unittestadapter', 'execution.py'); - - // return { - // script: executionScript, - // args: ['--udiscovery', ...args], - // }; + private async runTestsNew( + uri: Uri, + testIds: string[], + debugBool?: boolean, + executionFactory?: IPythonExecutionFactory, + ): Promise { + const deferred = createDeferred(); + const relativePathToPytest = 'pythonFiles'; + const fullPluginPath = path.join(EXTENSION_ROOT_DIR, relativePathToPytest); + this.configSettings.isTestExecution(); + const uuid = this.testServer.createUUID(uri.fsPath); + this.promiseMap.set(uuid, deferred); + const settings = this.configSettings.getSettings(uri); + const { pytestArgs } = settings.testing; + + const pythonPathParts: string[] = process.env.PYTHONPATH?.split(path.delimiter) ?? []; + const pythonPathCommand = [fullPluginPath, ...pythonPathParts].join(path.delimiter); + + const spawnOptions: SpawnOptions = { + cwd: uri.fsPath, + throwOnStdErr: true, + extraVariables: { + PYTHONPATH: pythonPathCommand, + TEST_UUID: uuid.toString(), + TEST_PORT: this.testServer.getPort().toString(), + }, + outputChannel: this.outputChannel, + }; + + // Create the Python environment in which to execute the command. + const creationOptions: ExecutionFactoryCreateWithEnvironmentOptions = { + allowEnvironmentFetchExceptions: false, + resource: uri, + }; + // need to check what will happen in the exec service is NOT defined and is null + const execService = await executionFactory?.createActivatedEnvironment(creationOptions); + + try { + // Remove positional test folders and files, we will add as needed per node + const testArgs = removePositionalFoldersAndFiles(pytestArgs); + + // if user has provided `--rootdir` then use that, otherwise add `cwd` + if (testArgs.filter((a) => a.startsWith('--rootdir')).length === 0) { + // Make sure root dir is set so pytest can find the relative paths + testArgs.splice(0, 0, '--rootdir', uri.fsPath); + } + + if (debugBool && !testArgs.some((a) => a.startsWith('--capture') || a === '-s')) { + testArgs.push('--capture', 'no'); + } + + console.debug(`Running test with arguments: ${testArgs.join(' ')}\r\n`); + console.debug(`Current working directory: ${uri.fsPath}\r\n`); + + const argArray = ['-m', 'pytest', '-p', 'vscode_pytest'].concat(testArgs).concat(testIds); + console.debug('argArray', argArray); + execService?.exec(argArray, spawnOptions); + } catch (ex) { + console.debug(`Error while running tests: ${testIds}\r\n${ex}\r\n\r\n`); + return Promise.reject(ex); + } + + return deferred.promise; + } } diff --git a/extensions/positron-python/src/client/testing/testController/pytest/runner.ts b/extensions/positron-python/src/client/testing/testController/pytest/runner.ts index 7bc045b3468..2c6cff72439 100644 --- a/extensions/positron-python/src/client/testing/testController/pytest/runner.ts +++ b/extensions/positron-python/src/client/testing/testController/pytest/runner.ts @@ -1,12 +1,11 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { inject, injectable, named } from 'inversify'; +import { inject, injectable } from 'inversify'; import { Disposable, TestItem, TestRun, TestRunProfileKind } from 'vscode'; -import { IOutputChannel } from '../../../common/types'; +import { ITestOutputChannel } from '../../../common/types'; import { PYTEST_PROVIDER } from '../../common/constants'; import { ITestDebugLauncher, ITestRunner, LaunchOptions, Options } from '../../common/types'; -import { TEST_OUTPUT_CHANNEL } from '../../constants'; import { filterArguments, getOptionValues } from '../common/argumentsHelper'; import { createTemporaryFile } from '../common/externalDependencies'; import { updateResultFromJunitXml } from '../common/resultsHelper'; @@ -32,7 +31,7 @@ export class PytestRunner implements ITestsRunner { constructor( @inject(ITestRunner) private readonly runner: ITestRunner, @inject(ITestDebugLauncher) private readonly debugLauncher: ITestDebugLauncher, - @inject(IOutputChannel) @named(TEST_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, + @inject(ITestOutputChannel) private readonly outputChannel: ITestOutputChannel, ) {} public async runTests( diff --git a/extensions/positron-python/src/client/testing/testController/unittest/runner.ts b/extensions/positron-python/src/client/testing/testController/unittest/runner.ts index d6bbb59ee64..d558f051ecc 100644 --- a/extensions/positron-python/src/client/testing/testController/unittest/runner.ts +++ b/extensions/positron-python/src/client/testing/testController/unittest/runner.ts @@ -1,16 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { injectable, inject, named } from 'inversify'; +import { injectable, inject } from 'inversify'; import { Location, TestController, TestItem, TestMessage, TestRun, TestRunProfileKind } from 'vscode'; import * as internalScripts from '../../../common/process/internal/scripts'; import { splitLines } from '../../../common/stringUtils'; -import { IOutputChannel } from '../../../common/types'; +import { ITestOutputChannel } from '../../../common/types'; import { noop } from '../../../common/utils/misc'; -import { traceError, traceInfo } from '../../../logging'; +import { traceError, traceVerbose } from '../../../logging'; import { UNITTEST_PROVIDER } from '../../common/constants'; import { ITestRunner, ITestDebugLauncher, IUnitTestSocketServer, LaunchOptions, Options } from '../../common/types'; -import { TEST_OUTPUT_CHANNEL } from '../../constants'; import { clearAllChildren, getTestCaseNodes } from '../common/testItemUtilities'; import { ITestRun, ITestsRunner, TestData, TestRunInstanceOptions, TestRunOptions } from '../common/types'; import { fixLogLines } from '../common/utils'; @@ -33,7 +32,7 @@ export class UnittestRunner implements ITestsRunner { constructor( @inject(ITestRunner) private readonly runner: ITestRunner, @inject(ITestDebugLauncher) private readonly debugLauncher: ITestDebugLauncher, - @inject(IOutputChannel) @named(TEST_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel, + @inject(ITestOutputChannel) private readonly outputChannel: ITestOutputChannel, @inject(IUnitTestSocketServer) private readonly server: IUnitTestSocketServer, ) {} @@ -99,7 +98,7 @@ export class UnittestRunner implements ITestsRunner { traceError(`${message} ${data.join(' ')}`); }); this.server.on('log', (message: string, ...data: string[]) => { - traceInfo(`${message} ${data.join(' ')}`); + traceVerbose(`${message} ${data.join(' ')}`); }); this.server.on('connect', noop); this.server.on('start', noop); 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 f0a5a957807..3f8ecb5797d 100644 --- a/extensions/positron-python/src/client/testing/testController/unittest/testDiscoveryAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/unittest/testDiscoveryAdapter.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { Uri } from 'vscode'; -import { IConfigurationService } from '../../../common/types'; +import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { createDeferred, Deferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { @@ -19,46 +19,51 @@ import { * Wrapper class for unittest test discovery. This is where we call `runTestCommand`. */ export class UnittestTestDiscoveryAdapter implements ITestDiscoveryAdapter { - private deferred: Deferred | undefined; + private promiseMap: Map> = new Map(); private cwd: string | undefined; - constructor(public testServer: ITestServer, public configSettings: IConfigurationService) { + constructor( + public testServer: ITestServer, + public configSettings: IConfigurationService, + private readonly outputChannel: ITestOutputChannel, + ) { testServer.onDataReceived(this.onDataReceivedHandler, this); } - public onDataReceivedHandler({ cwd, data }: DataReceivedEvent): void { - if (this.deferred && cwd === this.cwd) { - const testData: DiscoveredTestPayload = JSON.parse(data); - - this.deferred.resolve(testData); - this.deferred = undefined; + public onDataReceivedHandler({ uuid, data }: DataReceivedEvent): void { + const deferred = this.promiseMap.get(uuid); + if (deferred) { + deferred.resolve(JSON.parse(data)); + this.promiseMap.delete(uuid); } } public async discoverTests(uri: Uri): Promise { - if (!this.deferred) { - const settings = this.configSettings.getSettings(uri); - const { unittestArgs } = settings.testing; + const deferred = createDeferred(); + const settings = this.configSettings.getSettings(uri); + const { unittestArgs } = settings.testing; - const command = buildDiscoveryCommand(unittestArgs); + const command = buildDiscoveryCommand(unittestArgs); - this.cwd = uri.fsPath; + this.cwd = uri.fsPath; + const uuid = this.testServer.createUUID(uri.fsPath); - const options: TestCommandOptions = { - workspaceFolder: uri, - command, - cwd: this.cwd, - }; + const options: TestCommandOptions = { + workspaceFolder: uri, + command, + cwd: this.cwd, + uuid, + outChannel: this.outputChannel, + }; - this.deferred = createDeferred(); + this.promiseMap.set(uuid, deferred); - // Send the test command to the server. - // The server will fire an onDataReceived event once it gets a response. - this.testServer.sendCommand(options); - } + // Send the test command to the server. + // The server will fire an onDataReceived event once it gets a response. + this.testServer.sendCommand(options); - return this.deferred.promise; + return deferred.promise; } } 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 d71dea9fea3..b39e0cd2956 100644 --- a/extensions/positron-python/src/client/testing/testController/unittest/testExecutionAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/unittest/testExecutionAdapter.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { Uri } from 'vscode'; -import { IConfigurationService } from '../../../common/types'; +import { IConfigurationService, ITestOutputChannel } from '../../../common/types'; import { createDeferred, Deferred } from '../../../common/utils/async'; import { EXTENSION_ROOT_DIR } from '../../../constants'; import { @@ -20,46 +20,52 @@ import { */ export class UnittestTestExecutionAdapter implements ITestExecutionAdapter { - private deferred: Deferred | undefined; + private promiseMap: Map> = new Map(); private cwd: string | undefined; - constructor(public testServer: ITestServer, public configSettings: IConfigurationService) { + constructor( + public testServer: ITestServer, + public configSettings: IConfigurationService, + private readonly outputChannel: ITestOutputChannel, + ) { testServer.onDataReceived(this.onDataReceivedHandler, this); } - public onDataReceivedHandler({ cwd, data }: DataReceivedEvent): void { - if (this.deferred && cwd === this.cwd) { - const testData: ExecutionTestPayload = JSON.parse(data); - - this.deferred.resolve(testData); - this.deferred = undefined; + public onDataReceivedHandler({ uuid, data }: DataReceivedEvent): void { + const deferred = this.promiseMap.get(uuid); + if (deferred) { + deferred.resolve(JSON.parse(data)); + this.promiseMap.delete(uuid); } } public async runTests(uri: Uri, testIds: string[], debugBool?: boolean): Promise { - if (!this.deferred) { - const settings = this.configSettings.getSettings(uri); - const { unittestArgs } = settings.testing; + const settings = this.configSettings.getSettings(uri); + const { unittestArgs } = settings.testing; - const command = buildExecutionCommand(unittestArgs); - this.cwd = uri.fsPath; + const command = buildExecutionCommand(unittestArgs); + this.cwd = uri.fsPath; + const uuid = this.testServer.createUUID(uri.fsPath); - const options: TestCommandOptions = { - workspaceFolder: uri, - command, - cwd: this.cwd, - debugBool, - testIds, - }; + const options: TestCommandOptions = { + workspaceFolder: uri, + command, + cwd: this.cwd, + uuid, + debugBool, + testIds, + outChannel: this.outputChannel, + }; - this.deferred = createDeferred(); + const deferred = createDeferred(); + this.promiseMap.set(uuid, deferred); - // send test command to server - // server fire onDataReceived event once it gets response - this.testServer.sendCommand(options); - } - return this.deferred.promise; + // Send test command to server. + // Server fire onDataReceived event once it gets response. + this.testServer.sendCommand(options); + + return deferred.promise; } } diff --git a/extensions/positron-python/src/client/testing/testController/workspaceTestAdapter.ts b/extensions/positron-python/src/client/testing/testController/workspaceTestAdapter.ts index dc9c65c431f..39efc67f7c7 100644 --- a/extensions/positron-python/src/client/testing/testController/workspaceTestAdapter.ts +++ b/extensions/positron-python/src/client/testing/testController/workspaceTestAdapter.ts @@ -17,11 +17,12 @@ import { import { splitLines } from '../../common/stringUtils'; import { createDeferred, Deferred } from '../../common/utils/async'; import { Testing } from '../../common/utils/localize'; -import { traceError } from '../../logging'; +import { traceError, traceVerbose } from '../../logging'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { TestProvider } from '../types'; import { + clearAllChildren, createErrorTestItem, DebugTestTag, ErrorTestItemOptions, @@ -36,6 +37,7 @@ import { ITestExecutionAdapter, } from './common/types'; import { fixLogLines } from './common/utils'; +import { IPythonExecutionFactory } from '../../common/process/types'; /** * This class exposes a test-provider-agnostic way of discovering tests. @@ -68,13 +70,13 @@ export class WorkspaceTestAdapter { this.vsIdToRunId = new Map(); } - // ** add executionFactory?: IPythonExecutionFactory, to the parameters public async executeTests( testController: TestController, runInstance: TestRun, includes: TestItem[], token?: CancellationToken, debugBool?: boolean, + executionFactory?: IPythonExecutionFactory, ): Promise { if (this.executing) { return this.executing.promise; @@ -101,31 +103,34 @@ export class WorkspaceTestAdapter { } }); - // ** First line is old way, section with if statement below is new way. - rawTestExecData = await this.executionAdapter.runTests(this.workspaceUri, testCaseIds, debugBool); - // if (executionFactory !== undefined) { - // rawTestExecData = await this.executionAdapter.runTests( - // this.workspaceUri, - // testCaseIds, - // debugBool, - // executionFactory, - // ); - // } else { - // traceVerbose('executionFactory is undefined'); - // } + // ** execution factory only defined for new rewrite way + if (executionFactory !== undefined) { + rawTestExecData = await this.executionAdapter.runTests( + this.workspaceUri, + testCaseIds, + debugBool, + executionFactory, + ); + traceVerbose('executionFactory defined'); + } else { + rawTestExecData = await this.executionAdapter.runTests(this.workspaceUri, testCaseIds, debugBool); + } deferred.resolve(); } catch (ex) { // handle token and telemetry here sendTelemetryEvent(EventName.UNITTEST_RUN_ALL_FAILED, undefined); - const cancel = token?.isCancellationRequested + let cancel = token?.isCancellationRequested ? Testing.cancelUnittestExecution : Testing.errorUnittestExecution; + if (this.testProvider === 'pytest') { + cancel = token?.isCancellationRequested ? Testing.cancelPytestExecution : Testing.errorPytestExecution; + } traceError(`${cancel}\r\n`, ex); // Also report on the test view const message = util.format(`${cancel} ${Testing.seePythonOutput}\r\n`, ex); - const options = buildErrorNodeOptions(this.workspaceUri, message); + const options = buildErrorNodeOptions(this.workspaceUri, message, this.testProvider); const errorNode = createErrorTestItem(testController, options); testController.items.add(errorNode); @@ -135,8 +140,11 @@ export class WorkspaceTestAdapter { } if (rawTestExecData !== undefined && rawTestExecData.result !== undefined) { + // Map which holds the subtest information for each test item. + const subTestStats: Map = new Map(); + + // iterate through payload and update the UI accordingly. for (const keyTemp of Object.keys(rawTestExecData.result)) { - // check for result and update the UI accordingly. const testCases: TestItem[] = []; // grab leaf level test items @@ -147,7 +155,6 @@ export class WorkspaceTestAdapter { if ( rawTestExecData.result[keyTemp].outcome === 'failure' || - rawTestExecData.result[keyTemp].outcome === 'subtest-failure' || rawTestExecData.result[keyTemp].outcome === 'passed-unexpected' ) { const rawTraceback = rawTestExecData.result[keyTemp].traceback ?? ''; @@ -175,8 +182,7 @@ export class WorkspaceTestAdapter { }); } else if ( rawTestExecData.result[keyTemp].outcome === 'success' || - rawTestExecData.result[keyTemp].outcome === 'expected-failure' || - rawTestExecData.result[keyTemp].outcome === 'subtest-passed' + rawTestExecData.result[keyTemp].outcome === 'expected-failure' ) { const grabTestItem = this.runIdToTestItem.get(keyTemp); const grabVSid = this.runIdToVSid.get(keyTemp); @@ -203,18 +209,85 @@ export class WorkspaceTestAdapter { } }); } + } else if (rawTestExecData.result[keyTemp].outcome === 'subtest-failure') { + // split on " " since the subtest ID has the parent test ID in the first part of the ID. + const parentTestCaseId = keyTemp.split(' ')[0]; + const parentTestItem = this.runIdToTestItem.get(parentTestCaseId); + const data = rawTestExecData.result[keyTemp]; + // find the subtest's parent test item + if (parentTestItem) { + const subtestStats = subTestStats.get(parentTestCaseId); + if (subtestStats) { + subtestStats.failed += 1; + } else { + subTestStats.set(parentTestCaseId, { failed: 1, passed: 0 }); + runInstance.appendOutput(fixLogLines(`${parentTestCaseId} [subtests]:\r\n`)); + // clear since subtest items don't persist between runs + clearAllChildren(parentTestItem); + } + const subtestId = keyTemp; + const subTestItem = testController?.createTestItem(subtestId, subtestId); + runInstance.appendOutput(fixLogLines(`${subtestId} Failed\r\n`)); + // create a new test item for the subtest + if (subTestItem) { + const traceback = data.traceback ?? ''; + const text = `${data.subtest} Failed: ${data.message ?? data.outcome}\r\n${traceback}\r\n`; + runInstance.appendOutput(fixLogLines(text)); + parentTestItem.children.add(subTestItem); + runInstance.started(subTestItem); + const message = new TestMessage(rawTestExecData?.result[keyTemp].message ?? ''); + if (parentTestItem.uri && parentTestItem.range) { + message.location = new Location(parentTestItem.uri, parentTestItem.range); + } + runInstance.failed(subTestItem, message); + } else { + throw new Error('Unable to create new child node for subtest'); + } + } else { + throw new Error('Parent test item not found'); + } + } else if (rawTestExecData.result[keyTemp].outcome === 'subtest-success') { + // split on " " since the subtest ID has the parent test ID in the first part of the ID. + const parentTestCaseId = keyTemp.split(' ')[0]; + const parentTestItem = this.runIdToTestItem.get(parentTestCaseId); + + // find the subtest's parent test item + if (parentTestItem) { + const subtestStats = subTestStats.get(parentTestCaseId); + if (subtestStats) { + subtestStats.passed += 1; + } else { + subTestStats.set(parentTestCaseId, { failed: 0, passed: 1 }); + runInstance.appendOutput(fixLogLines(`${parentTestCaseId} [subtests]:\r\n`)); + // clear since subtest items don't persist between runs + clearAllChildren(parentTestItem); + } + const subtestId = keyTemp; + const subTestItem = testController?.createTestItem(subtestId, subtestId); + // create a new test item for the subtest + if (subTestItem) { + parentTestItem.children.add(subTestItem); + runInstance.started(subTestItem); + runInstance.passed(subTestItem); + runInstance.appendOutput(fixLogLines(`${subtestId} Passed\r\n`)); + } else { + throw new Error('Unable to create new child node for subtest'); + } + } else { + throw new Error('Parent test item not found'); + } } } } return Promise.resolve(); } - // add `executionFactory?: IPythonExecutionFactory,` to the function for new pytest method public async discoverTests( testController: TestController, token?: CancellationToken, isMultiroot?: boolean, workspaceFilePath?: string, + executionFactory?: IPythonExecutionFactory, ): Promise { sendTelemetryEvent(EventName.UNITTEST_DISCOVERING, undefined, { tool: this.testProvider }); @@ -230,26 +303,28 @@ export class WorkspaceTestAdapter { let rawTestData; try { - // ** First line is old way, section with if statement below is new way. - rawTestData = await this.discoveryAdapter.discoverTests(this.workspaceUri); - // if (executionFactory !== undefined) { - // rawTestData = await this.discoveryAdapter.discoverTests(this.workspaceUri, executionFactory); - // } else { - // traceVerbose('executionFactory is undefined'); - // } + // ** execution factory only defined for new rewrite way + if (executionFactory !== undefined) { + rawTestData = await this.discoveryAdapter.discoverTests(this.workspaceUri, executionFactory); + } else { + rawTestData = await this.discoveryAdapter.discoverTests(this.workspaceUri); + } deferred.resolve(); } catch (ex) { sendTelemetryEvent(EventName.UNITTEST_DISCOVERY_DONE, undefined, { tool: this.testProvider, failed: true }); - const cancel = token?.isCancellationRequested + let cancel = token?.isCancellationRequested ? Testing.cancelUnittestDiscovery : Testing.errorUnittestDiscovery; + if (this.testProvider === 'pytest') { + cancel = token?.isCancellationRequested ? Testing.cancelPytestDiscovery : Testing.errorPytestDiscovery; + } traceError(`${cancel}\r\n`, ex); // Report also on the test view. const message = util.format(`${cancel} ${Testing.seePythonOutput}\r\n`, ex); - const options = buildErrorNodeOptions(this.workspaceUri, message); + const options = buildErrorNodeOptions(this.workspaceUri, message, this.testProvider); const errorNode = createErrorTestItem(testController, options); testController.items.add(errorNode); @@ -267,17 +342,19 @@ export class WorkspaceTestAdapter { // Check if there were any errors in the discovery process. if (rawTestData.status === 'error') { + const testingErrorConst = + this.testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery; const { errors } = rawTestData; - traceError(Testing.errorUnittestDiscovery, '\r\n', errors!.join('\r\n\r\n')); + traceError(testingErrorConst, '\r\n', errors!.join('\r\n\r\n')); let errorNode = testController.items.get(`DiscoveryError:${workspacePath}`); const message = util.format( - `${Testing.errorUnittestDiscovery} ${Testing.seePythonOutput}\r\n`, + `${testingErrorConst} ${Testing.seePythonOutput}\r\n`, errors!.join('\r\n\r\n'), ); if (errorNode === undefined) { - const options = buildErrorNodeOptions(this.workspaceUri, message); + const options = buildErrorNodeOptions(this.workspaceUri, message, this.testProvider); errorNode = createErrorTestItem(testController, options); testController.items.add(errorNode); } @@ -393,10 +470,11 @@ function populateTestTree( }); } -function buildErrorNodeOptions(uri: Uri, message: string): ErrorTestItemOptions { +function buildErrorNodeOptions(uri: Uri, message: string, testType: string): ErrorTestItemOptions { + const labelText = testType === 'pytest' ? 'Pytest Discovery Error' : 'Unittest Discovery Error'; return { id: `DiscoveryError:${uri.fsPath}`, - label: `Unittest Discovery Error [${path.basename(uri.fsPath)}]`, + label: `${labelText} [${path.basename(uri.fsPath)}]`, error: message, }; } diff --git a/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts b/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts index d4781f7e03e..d5e97f93768 100644 --- a/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts +++ b/extensions/positron-python/src/test/activation/node/analysisOptions.unit.test.ts @@ -2,14 +2,14 @@ // Licensed under the MIT License. import { assert, expect } from 'chai'; import * as typemoq from 'typemoq'; -import { WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { WorkspaceFolder } from 'vscode'; import { DocumentFilter } from 'vscode-languageclient/node'; import { NodeLanguageServerAnalysisOptions } from '../../../client/activation/node/analysisOptions'; import { ILanguageServerOutputChannel } from '../../../client/activation/types'; import { IWorkspaceService } from '../../../client/common/application/types'; import { PYTHON, PYTHON_LANGUAGE } from '../../../client/common/constants'; -import { IExperimentService, IOutputChannel } from '../../../client/common/types'; +import { ILogOutputChannel } from '../../../client/common/types'; suite('Pylance Language Server - Analysis Options', () => { class TestClass extends NodeLanguageServerAnalysisOptions { @@ -28,22 +28,17 @@ suite('Pylance Language Server - Analysis Options', () => { } let analysisOptions: TestClass; - let outputChannel: IOutputChannel; + let outputChannel: ILogOutputChannel; let lsOutputChannel: typemoq.IMock; let workspace: typemoq.IMock; - let experimentService: IExperimentService; setup(() => { - outputChannel = typemoq.Mock.ofType().object; + outputChannel = typemoq.Mock.ofType().object; workspace = typemoq.Mock.ofType(); workspace.setup((w) => w.isVirtualWorkspace).returns(() => false); - const workspaceConfig = typemoq.Mock.ofType(); - workspace.setup((w) => w.getConfiguration('editor', undefined, true)).returns(() => workspaceConfig.object); - workspaceConfig.setup((w) => w.get('formatOnType')).returns(() => true); lsOutputChannel = typemoq.Mock.ofType(); lsOutputChannel.setup((l) => l.channel).returns(() => outputChannel); - experimentService = typemoq.Mock.ofType().object; - analysisOptions = new TestClass(lsOutputChannel.object, workspace.object, experimentService); + analysisOptions = new TestClass(lsOutputChannel.object, workspace.object); }); test('Workspace folder is undefined', () => { 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 472dea14750..256e57a5d72 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 @@ -19,7 +19,7 @@ import { } from '../../../client/interpreter/contracts'; import { IInterpreterSelector } from '../../../client/interpreter/configuration/types'; import { IEnvironmentActivationService } from '../../../client/interpreter/activation/types'; -import { IWorkspaceService } from '../../../client/common/application/types'; +import { IContextKeyManager, IWorkspaceService } from '../../../client/common/application/types'; import { MockMemento } from '../../mocks/mementos'; suite('Pylance Language Server - Interactive Window LSP Notebooks', () => { @@ -41,6 +41,7 @@ suite('Pylance Language Server - Interactive Window LSP Notebooks', () => { mock(), mock(), mock(), + mock(), ); jupyterApi.registerGetNotebookUriForTextDocumentUriFunction(getNotebookUriFunction); }); diff --git a/extensions/positron-python/src/test/activation/outputChannel.unit.test.ts b/extensions/positron-python/src/test/activation/outputChannel.unit.test.ts index f7d827b4782..f8f38783bb0 100644 --- a/extensions/positron-python/src/test/activation/outputChannel.unit.test.ts +++ b/extensions/positron-python/src/test/activation/outputChannel.unit.test.ts @@ -7,7 +7,7 @@ import { expect } from 'chai'; import * as TypeMoq from 'typemoq'; import { LanguageServerOutputChannel } from '../../client/activation/common/outputChannel'; import { IApplicationShell, ICommandManager } from '../../client/common/application/types'; -import { IOutputChannel } from '../../client/common/types'; +import { ILogOutputChannel } from '../../client/common/types'; import { sleep } from '../../client/common/utils/async'; import { OutputChannelNames } from '../../client/common/utils/localize'; @@ -15,10 +15,10 @@ suite('Language Server Output Channel', () => { let appShell: TypeMoq.IMock; let languageServerOutputChannel: LanguageServerOutputChannel; let commandManager: TypeMoq.IMock; - let output: TypeMoq.IMock; + let output: TypeMoq.IMock; setup(() => { appShell = TypeMoq.Mock.ofType(); - output = TypeMoq.Mock.ofType(); + output = TypeMoq.Mock.ofType(); commandManager = TypeMoq.Mock.ofType(); languageServerOutputChannel = new LanguageServerOutputChannel(appShell.object, commandManager.object, []); }); diff --git a/extensions/positron-python/src/test/common/installer.test.ts b/extensions/positron-python/src/test/common/installer.test.ts index daea3e44ec7..7ff0ee81c27 100644 --- a/extensions/positron-python/src/test/common/installer.test.ts +++ b/extensions/positron-python/src/test/common/installer.test.ts @@ -51,6 +51,7 @@ import { TerminalActivator } from '../../client/common/terminal/activator'; import { PowershellTerminalActivationFailedHandler } from '../../client/common/terminal/activator/powershellFailedHandler'; import { Bash } from '../../client/common/terminal/environmentActivationProviders/bash'; import { CommandPromptAndPowerShell } from '../../client/common/terminal/environmentActivationProviders/commandPrompt'; +import { Nushell } from '../../client/common/terminal/environmentActivationProviders/nushell'; import { CondaActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/condaActivationProvider'; import { PipEnvActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/pipEnvActivationProvider'; import { PyEnvActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/pyenvActivationProvider'; @@ -214,6 +215,11 @@ suite('Installer', () => { CommandPromptAndPowerShell, TerminalActivationProviders.commandPromptAndPowerShell, ); + ioc.serviceManager.addSingleton( + ITerminalActivationCommandProvider, + Nushell, + TerminalActivationProviders.nushell, + ); ioc.serviceManager.addSingleton( ITerminalActivationCommandProvider, PyEnvActivationCommandProvider, diff --git a/extensions/positron-python/src/test/common/installer/moduleInstaller.unit.test.ts b/extensions/positron-python/src/test/common/installer/moduleInstaller.unit.test.ts index 5c77f816549..01ac0e31555 100644 --- a/extensions/positron-python/src/test/common/installer/moduleInstaller.unit.test.ts +++ b/extensions/positron-python/src/test/common/installer/moduleInstaller.unit.test.ts @@ -13,7 +13,6 @@ import { anything, instance, mock, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; import { CancellationTokenSource, Disposable, ProgressLocation, Uri, WorkspaceConfiguration } from 'vscode'; import { IApplicationShell, IWorkspaceService } from '../../../client/common/application/types'; -import { STANDARD_OUTPUT_CHANNEL } from '../../../client/common/constants'; import { CondaInstaller } from '../../../client/common/installer/condaInstaller'; import { ModuleInstaller } from '../../../client/common/installer/moduleInstaller'; import { PipEnvInstaller, pipenvName } from '../../../client/common/installer/pipEnvInstaller'; @@ -32,7 +31,7 @@ import { IConfigurationService, IDisposableRegistry, IInstaller, - IOutputChannel, + ILogOutputChannel, IPythonSettings, Product, } from '../../../client/common/types'; @@ -89,7 +88,7 @@ suite('Module Installer', () => { return super.elevatedInstall(execPath, args); } } - let outputChannel: TypeMoq.IMock; + let outputChannel: TypeMoq.IMock; let appShell: TypeMoq.IMock; let serviceContainer: TypeMoq.IMock; @@ -104,9 +103,9 @@ suite('Module Installer', () => { traceLogStub = sinon.stub(logging, 'traceLog'); serviceContainer = TypeMoq.Mock.ofType(); - outputChannel = TypeMoq.Mock.ofType(); + outputChannel = TypeMoq.Mock.ofType(); serviceContainer - .setup((c) => c.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isValue(STANDARD_OUTPUT_CHANNEL))) + .setup((c) => c.get(TypeMoq.It.isValue(ILogOutputChannel))) .returns(() => outputChannel.object); appShell = TypeMoq.Mock.ofType(); serviceContainer.setup((c) => c.get(TypeMoq.It.isValue(IApplicationShell))).returns(() => appShell.object); diff --git a/extensions/positron-python/src/test/common/installer/poetryInstaller.unit.test.ts b/extensions/positron-python/src/test/common/installer/poetryInstaller.unit.test.ts index 8c2c3614d18..07d60159138 100644 --- a/extensions/positron-python/src/test/common/installer/poetryInstaller.unit.test.ts +++ b/extensions/positron-python/src/test/common/installer/poetryInstaller.unit.test.ts @@ -50,12 +50,14 @@ suite('Module Installer - Poetry', () => { switch (command) { case 'poetry env list --full-path': return Promise.resolve>({ stdout: '' }); - case 'poetry env info -p': - if (options.cwd && externalDependencies.arePathsSame(options.cwd, project1)) { + case 'poetry env info -p': { + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); + if (cwd && externalDependencies.arePathsSame(cwd, project1)) { return Promise.resolve>({ stdout: `${path.join(project1, '.venv')} \n`, }); } + } } return Promise.reject(new Error('Command failed')); }); diff --git a/extensions/positron-python/src/test/common/moduleInstaller.test.ts b/extensions/positron-python/src/test/common/moduleInstaller.test.ts index fe4937f363e..a6b647ad181 100644 --- a/extensions/positron-python/src/test/common/moduleInstaller.test.ts +++ b/extensions/positron-python/src/test/common/moduleInstaller.test.ts @@ -50,6 +50,7 @@ import { TerminalActivator } from '../../client/common/terminal/activator'; import { PowershellTerminalActivationFailedHandler } from '../../client/common/terminal/activator/powershellFailedHandler'; import { Bash } from '../../client/common/terminal/environmentActivationProviders/bash'; import { CommandPromptAndPowerShell } from '../../client/common/terminal/environmentActivationProviders/commandPrompt'; +import { Nushell } from '../../client/common/terminal/environmentActivationProviders/nushell'; import { CondaActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/condaActivationProvider'; import { PipEnvActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/pipEnvActivationProvider'; import { PyEnvActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/pyenvActivationProvider'; @@ -225,6 +226,11 @@ suite('Module Installer', () => { CommandPromptAndPowerShell, TerminalActivationProviders.commandPromptAndPowerShell, ); + ioc.serviceManager.addSingleton( + ITerminalActivationCommandProvider, + Nushell, + TerminalActivationProviders.nushell, + ); ioc.serviceManager.addSingleton( ITerminalActivationCommandProvider, PyEnvActivationCommandProvider, diff --git a/extensions/positron-python/src/test/common/process/proc.unit.test.ts b/extensions/positron-python/src/test/common/process/proc.unit.test.ts index 4505ab817be..38cf450bef5 100644 --- a/extensions/positron-python/src/test/common/process/proc.unit.test.ts +++ b/extensions/positron-python/src/test/common/process/proc.unit.test.ts @@ -36,14 +36,18 @@ suite('Process - Process Service', function () { test('Process is killed', async () => { const proc = spawnProc(); - - ProcessService.kill(proc.proc.pid); + expect(proc.proc.pid !== undefined).to.equal(true, 'invalid pid'); + if (proc.proc.pid) { + ProcessService.kill(proc.proc.pid); + } expect(await proc.exited.promise).to.equal(true, 'process did not die'); }); test('Process is alive', async () => { const proc = spawnProc(); - - expect(ProcessService.isAlive(proc.proc.pid)).to.equal(true, 'process is not alive'); + expect(proc.proc.pid !== undefined).to.equal(true, 'invalid pid'); + if (proc.proc.pid) { + expect(ProcessService.isAlive(proc.proc.pid)).to.equal(true, 'process is not alive'); + } }); }); diff --git a/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts b/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts index 5d764aac253..2964455ada3 100644 --- a/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts +++ b/extensions/positron-python/src/test/common/serviceRegistry.unit.test.ts @@ -40,6 +40,7 @@ import { TerminalActivator } from '../../client/common/terminal/activator'; import { PowershellTerminalActivationFailedHandler } from '../../client/common/terminal/activator/powershellFailedHandler'; import { Bash } from '../../client/common/terminal/environmentActivationProviders/bash'; import { CommandPromptAndPowerShell } from '../../client/common/terminal/environmentActivationProviders/commandPrompt'; +import { Nushell } from '../../client/common/terminal/environmentActivationProviders/nushell'; import { CondaActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/condaActivationProvider'; import { PipEnvActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/pipEnvActivationProvider'; import { PyEnvActivationCommandProvider } from '../../client/common/terminal/environmentActivationProviders/pyenvActivationProvider'; @@ -113,6 +114,7 @@ suite('Common - Service Registry', () => { CommandPromptAndPowerShell, TerminalActivationProviders.commandPromptAndPowerShell, ], + [ITerminalActivationCommandProvider, Nushell, TerminalActivationProviders.nushell], [IToolExecutionPath, PipEnvExecutionPath, ToolExecutionPath.pipenv], [ITerminalActivationCommandProvider, CondaActivationCommandProvider, TerminalActivationProviders.conda], [ITerminalActivationCommandProvider, PipEnvActivationCommandProvider, TerminalActivationProviders.pipenv], diff --git a/extensions/positron-python/src/test/common/terminals/activation.bash.unit.test.ts b/extensions/positron-python/src/test/common/terminals/activation.bash.unit.test.ts index 28bab49fd55..cd057e7be3e 100644 --- a/extensions/positron-python/src/test/common/terminals/activation.bash.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/activation.bash.unit.test.ts @@ -24,107 +24,113 @@ suite('Terminal Environment Activation (bash)', () => { ? 'and there are spaces in the script file (pythonpath),' : 'and there are no spaces in the script file (pythonpath),'; suite(suiteTitle, () => { - ['activate', 'activate.sh', 'activate.csh', 'activate.fish', 'activate.bat', 'Activate.ps1'].forEach( - (scriptFileName) => { - suite(`and script file is ${scriptFileName}`, () => { - let serviceContainer: TypeMoq.IMock; - let interpreterService: TypeMoq.IMock; - let fileSystem: TypeMoq.IMock; - setup(() => { - serviceContainer = TypeMoq.Mock.ofType(); - fileSystem = TypeMoq.Mock.ofType(); - serviceContainer.setup((c) => c.get(IFileSystem)).returns(() => fileSystem.object); + [ + 'activate', + 'activate.sh', + 'activate.csh', + 'activate.fish', + 'activate.bat', + 'activate.nu', + 'Activate.ps1', + ].forEach((scriptFileName) => { + suite(`and script file is ${scriptFileName}`, () => { + let serviceContainer: TypeMoq.IMock; + let interpreterService: TypeMoq.IMock; + let fileSystem: TypeMoq.IMock; + setup(() => { + serviceContainer = TypeMoq.Mock.ofType(); + fileSystem = TypeMoq.Mock.ofType(); + serviceContainer.setup((c) => c.get(IFileSystem)).returns(() => fileSystem.object); - interpreterService = TypeMoq.Mock.ofType(); - interpreterService - .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) - .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); - serviceContainer - .setup((c) => c.get(IInterpreterService)) - .returns(() => interpreterService.object); - }); + interpreterService = TypeMoq.Mock.ofType(); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); + serviceContainer + .setup((c) => c.get(IInterpreterService)) + .returns(() => interpreterService.object); + }); + + getNamesAndValues(TerminalShellType).forEach((shellType) => { + let isScriptFileSupported = false; + switch (shellType.value) { + case TerminalShellType.zsh: + case TerminalShellType.ksh: + case TerminalShellType.wsl: + case TerminalShellType.gitbash: + case TerminalShellType.bash: { + isScriptFileSupported = ['activate', 'activate.sh'].indexOf(scriptFileName) >= 0; + break; + } + case TerminalShellType.fish: { + isScriptFileSupported = ['activate.fish'].indexOf(scriptFileName) >= 0; + break; + } + case TerminalShellType.tcshell: + case TerminalShellType.cshell: { + isScriptFileSupported = ['activate.csh'].indexOf(scriptFileName) >= 0; + break; + } + default: { + isScriptFileSupported = false; + } + } + const titleTitle = isScriptFileSupported + ? `Ensure bash Activation command returns activation command (Shell: ${shellType.name})` + : `Ensure bash Activation command returns undefined (Shell: ${shellType.name})`; + + test(titleTitle, async () => { + const bash = new Bash(serviceContainer.object); - getNamesAndValues(TerminalShellType).forEach((shellType) => { - let isScriptFileSupported = false; + const supported = bash.isShellSupported(shellType.value); switch (shellType.value) { + case TerminalShellType.wsl: case TerminalShellType.zsh: case TerminalShellType.ksh: - case TerminalShellType.wsl: + case TerminalShellType.bash: case TerminalShellType.gitbash: - case TerminalShellType.bash: { - isScriptFileSupported = ['activate', 'activate.sh'].indexOf(scriptFileName) >= 0; - break; - } - case TerminalShellType.fish: { - isScriptFileSupported = ['activate.fish'].indexOf(scriptFileName) >= 0; - break; - } case TerminalShellType.tcshell: - case TerminalShellType.cshell: { - isScriptFileSupported = ['activate.csh'].indexOf(scriptFileName) >= 0; + case TerminalShellType.cshell: + case TerminalShellType.fish: { + expect(supported).to.be.equal( + true, + `${shellType.name} shell not supported (it should be)`, + ); break; } default: { - isScriptFileSupported = false; + expect(supported).to.be.equal( + false, + `${shellType.name} incorrectly supported (should not be)`, + ); + // No point proceeding with other tests. + return; } } - const titleTitle = isScriptFileSupported - ? `Ensure bash Activation command returns activation command (Shell: ${shellType.name})` - : `Ensure bash Activation command returns undefined (Shell: ${shellType.name})`; - test(titleTitle, async () => { - const bash = new Bash(serviceContainer.object); + const pathToScriptFile = path.join(path.dirname(pythonPath), scriptFileName); + fileSystem + .setup((fs) => fs.fileExists(TypeMoq.It.isValue(pathToScriptFile))) + .returns(() => Promise.resolve(true)); + const command = await bash.getActivationCommands(undefined, shellType.value); - const supported = bash.isShellSupported(shellType.value); - switch (shellType.value) { - case TerminalShellType.wsl: - case TerminalShellType.zsh: - case TerminalShellType.ksh: - case TerminalShellType.bash: - case TerminalShellType.gitbash: - case TerminalShellType.tcshell: - case TerminalShellType.cshell: - case TerminalShellType.fish: { - expect(supported).to.be.equal( - true, - `${shellType.name} shell not supported (it should be)`, - ); - break; - } - default: { - expect(supported).to.be.equal( - false, - `${shellType.name} incorrectly supported (should not be)`, - ); - // No point proceeding with other tests. - return; - } - } - - const pathToScriptFile = path.join(path.dirname(pythonPath), scriptFileName); - fileSystem - .setup((fs) => fs.fileExists(TypeMoq.It.isValue(pathToScriptFile))) - .returns(() => Promise.resolve(true)); - const command = await bash.getActivationCommands(undefined, shellType.value); - - if (isScriptFileSupported) { - // Ensure the script file is of the following form: - // source "" - // Ensure the path is quoted if it contains any spaces. - // Ensure it contains the name of the environment as an argument to the script file. + if (isScriptFileSupported) { + // Ensure the script file is of the following form: + // source "" + // Ensure the path is quoted if it contains any spaces. + // Ensure it contains the name of the environment as an argument to the script file. - expect(command).to.be.deep.equal( - [`source ${pathToScriptFile.fileToCommandArgumentForPythonExt()}`.trim()], - 'Invalid command', - ); - } else { - expect(command).to.be.equal(undefined, 'Command should be undefined'); - } - }); + expect(command).to.be.deep.equal( + [`source ${pathToScriptFile.fileToCommandArgumentForPythonExt()}`.trim()], + 'Invalid command', + ); + } else { + expect(command).to.be.equal(undefined, 'Command should be undefined'); + } }); }); - }, - ); + }); + }); }); }); }); diff --git a/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts b/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts index 904752d698c..84e4bffacfc 100644 --- a/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/activation.conda.unit.test.ts @@ -12,6 +12,7 @@ import { IFileSystem, IPlatformService } from '../../../client/common/platform/t import { IProcessService, IProcessServiceFactory } from '../../../client/common/process/types'; import { Bash } from '../../../client/common/terminal/environmentActivationProviders/bash'; import { CommandPromptAndPowerShell } from '../../../client/common/terminal/environmentActivationProviders/commandPrompt'; +import { Nushell } from '../../../client/common/terminal/environmentActivationProviders/nushell'; import { CondaActivationCommandProvider, _getPowershellCommands, @@ -110,6 +111,7 @@ suite('Terminal Environment Activation conda', () => { ), instance(bash), mock(CommandPromptAndPowerShell), + mock(Nushell), mock(PyEnvActivationCommandProvider), mock(PipEnvActivationCommandProvider), [], diff --git a/extensions/positron-python/src/test/common/terminals/activation.nushell.unit.test.ts b/extensions/positron-python/src/test/common/terminals/activation.nushell.unit.test.ts new file mode 100644 index 00000000000..bf748bc7c05 --- /dev/null +++ b/extensions/positron-python/src/test/common/terminals/activation.nushell.unit.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { expect } from 'chai'; +import * as path from 'path'; +import * as TypeMoq from 'typemoq'; +import '../../../client/common/extensions'; +import { IFileSystem } from '../../../client/common/platform/types'; +import { Nushell } from '../../../client/common/terminal/environmentActivationProviders/nushell'; +import { TerminalShellType } from '../../../client/common/terminal/types'; +import { getNamesAndValues } from '../../../client/common/utils/enum'; +import { IInterpreterService } from '../../../client/interpreter/contracts'; +import { IServiceContainer } from '../../../client/ioc/types'; +import { PythonEnvironment } from '../../../client/pythonEnvironments/info'; + +const pythonPath = 'usr/bin/python'; + +suite('Terminal Environment Activation (nushell)', () => { + for (const scriptFileName of ['activate', 'activate.sh', 'activate.nu']) { + suite(`and script file is ${scriptFileName}`, () => { + let serviceContainer: TypeMoq.IMock; + let interpreterService: TypeMoq.IMock; + let fileSystem: TypeMoq.IMock; + setup(() => { + serviceContainer = TypeMoq.Mock.ofType(); + fileSystem = TypeMoq.Mock.ofType(); + serviceContainer.setup((c) => c.get(IFileSystem)).returns(() => fileSystem.object); + + interpreterService = TypeMoq.Mock.ofType(); + interpreterService + .setup((i) => i.getActiveInterpreter(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(({ path: pythonPath } as unknown) as PythonEnvironment)); + serviceContainer.setup((c) => c.get(IInterpreterService)).returns(() => interpreterService.object); + }); + + for (const { name, value } of getNamesAndValues(TerminalShellType)) { + const isNushell = value === TerminalShellType.nushell; + const isScriptFileSupported = isNushell && ['activate.nu'].includes(scriptFileName); + const expectedReturn = isScriptFileSupported ? 'activation command' : 'undefined'; + + // eslint-disable-next-line no-loop-func -- setup() takes care of shellType and fileSystem reinitialization + test(`Ensure nushell Activation command returns ${expectedReturn} (Shell: ${name})`, async () => { + const nu = new Nushell(serviceContainer.object); + + const supported = nu.isShellSupported(value); + if (isNushell) { + expect(supported).to.be.equal(true, `${name} shell not supported (it should be)`); + } else { + expect(supported).to.be.equal(false, `${name} incorrectly supported (should not be)`); + // No point proceeding with other tests. + return; + } + + const pathToScriptFile = path.join(path.dirname(pythonPath), scriptFileName); + fileSystem + .setup((fs) => fs.fileExists(TypeMoq.It.isValue(pathToScriptFile))) + .returns(() => Promise.resolve(true)); + const command = await nu.getActivationCommands(undefined, value); + + if (isScriptFileSupported) { + expect(command).to.be.deep.equal( + [`overlay use ${pathToScriptFile.fileToCommandArgumentForPythonExt()}`.trim()], + 'Invalid command', + ); + } else { + expect(command).to.be.equal(undefined, 'Command should be undefined'); + } + }); + } + }); + } +}); diff --git a/extensions/positron-python/src/test/common/terminals/activator/index.unit.test.ts b/extensions/positron-python/src/test/common/terminals/activator/index.unit.test.ts index 9dff5a800ca..a50b946c391 100644 --- a/extensions/positron-python/src/test/common/terminals/activator/index.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/activator/index.unit.test.ts @@ -12,7 +12,12 @@ import { ITerminalActivator, ITerminalHelper, } from '../../../../client/common/terminal/types'; -import { IConfigurationService, IPythonSettings, ITerminalSettings } from '../../../../client/common/types'; +import { + IConfigurationService, + IExperimentService, + IPythonSettings, + ITerminalSettings, +} from '../../../../client/common/types'; suite('Terminal Activator', () => { let activator: TerminalActivator; @@ -20,9 +25,12 @@ suite('Terminal Activator', () => { let handler1: TypeMoq.IMock; let handler2: TypeMoq.IMock; let terminalSettings: TypeMoq.IMock; + let experimentService: TypeMoq.IMock; setup(() => { baseActivator = TypeMoq.Mock.ofType(); terminalSettings = TypeMoq.Mock.ofType(); + experimentService = TypeMoq.Mock.ofType(); + experimentService.setup((e) => e.inExperimentSync(TypeMoq.It.isAny())).returns(() => false); handler1 = TypeMoq.Mock.ofType(); handler2 = TypeMoq.Mock.ofType(); const configService = TypeMoq.Mock.ofType(); @@ -37,7 +45,12 @@ suite('Terminal Activator', () => { protected initialize() { this.baseActivator = baseActivator.object; } - })(TypeMoq.Mock.ofType().object, [handler1.object, handler2.object], configService.object); + })( + TypeMoq.Mock.ofType().object, + [handler1.object, handler2.object], + configService.object, + experimentService.object, + ); }); async function testActivationAndHandlers( activationSuccessful: boolean, diff --git a/extensions/positron-python/src/test/common/terminals/helper.unit.test.ts b/extensions/positron-python/src/test/common/terminals/helper.unit.test.ts index 59ac56ebf82..b6a8d44ac03 100644 --- a/extensions/positron-python/src/test/common/terminals/helper.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/helper.unit.test.ts @@ -13,6 +13,7 @@ import { PlatformService } from '../../../client/common/platform/platformService import { IPlatformService } from '../../../client/common/platform/types'; import { Bash } from '../../../client/common/terminal/environmentActivationProviders/bash'; import { CommandPromptAndPowerShell } from '../../../client/common/terminal/environmentActivationProviders/commandPrompt'; +import { Nushell } from '../../../client/common/terminal/environmentActivationProviders/nushell'; import { CondaActivationCommandProvider } from '../../../client/common/terminal/environmentActivationProviders/condaActivationProvider'; import { PipEnvActivationCommandProvider } from '../../../client/common/terminal/environmentActivationProviders/pipEnvActivationProvider'; import { PyEnvActivationCommandProvider } from '../../../client/common/terminal/environmentActivationProviders/pyenvActivationProvider'; @@ -42,6 +43,7 @@ suite('Terminal Service helpers', () => { let condaActivationProvider: ITerminalActivationCommandProvider; let bashActivationProvider: ITerminalActivationCommandProvider; let cmdActivationProvider: ITerminalActivationCommandProvider; + let nushellActivationProvider: ITerminalActivationCommandProvider; let pyenvActivationProvider: ITerminalActivationCommandProvider; let pipenvActivationProvider: ITerminalActivationCommandProvider; let pythonSettings: PythonSettings; @@ -67,6 +69,7 @@ suite('Terminal Service helpers', () => { condaActivationProvider = mock(CondaActivationCommandProvider); bashActivationProvider = mock(Bash); cmdActivationProvider = mock(CommandPromptAndPowerShell); + nushellActivationProvider = mock(Nushell); pyenvActivationProvider = mock(PyEnvActivationCommandProvider); pipenvActivationProvider = mock(PipEnvActivationCommandProvider); pythonSettings = mock(PythonSettings); @@ -80,6 +83,7 @@ suite('Terminal Service helpers', () => { instance(condaActivationProvider), instance(bashActivationProvider), instance(cmdActivationProvider), + instance(nushellActivationProvider), instance(pyenvActivationProvider), instance(pipenvActivationProvider), [instance(mockDetector)], @@ -213,6 +217,7 @@ suite('Terminal Service helpers', () => { when(bashActivationProvider.isShellSupported(anything())).thenReturn(false); when(cmdActivationProvider.isShellSupported(anything())).thenReturn(false); + when(nushellActivationProvider.isShellSupported(anything())).thenReturn(false); when(pyenvActivationProvider.isShellSupported(anything())).thenReturn(false); when(pipenvActivationProvider.isShellSupported(anything())).thenReturn(false); @@ -225,6 +230,7 @@ suite('Terminal Service helpers', () => { verify(pythonSettings.pythonPath).once(); verify(condaService.isCondaEnvironment(pythonPath)).once(); verify(bashActivationProvider.isShellSupported(anything())).atLeast(1); + verify(nushellActivationProvider.isShellSupported(anything())).atLeast(1); verify(pyenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(pipenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(cmdActivationProvider.isShellSupported(anything())).atLeast(1); @@ -238,6 +244,7 @@ suite('Terminal Service helpers', () => { when(bashActivationProvider.isShellSupported(anything())).thenReturn(true); when(cmdActivationProvider.isShellSupported(anything())).thenReturn(false); + when(nushellActivationProvider.isShellSupported(anything())).thenReturn(false); when(pyenvActivationProvider.isShellSupported(anything())).thenReturn(false); when(pipenvActivationProvider.isShellSupported(anything())).thenReturn(false); @@ -248,6 +255,7 @@ suite('Terminal Service helpers', () => { verify(condaService.isCondaEnvironment(pythonPath)).once(); verify(bashActivationProvider.isShellSupported(anything())).atLeast(1); verify(bashActivationProvider.getActivationCommands(resource, anything())).once(); + verify(nushellActivationProvider.isShellSupported(anything())).atLeast(1); verify(pyenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(pipenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(cmdActivationProvider.isShellSupported(anything())).atLeast(1); @@ -262,7 +270,12 @@ suite('Terminal Service helpers', () => { ); when(pipenvActivationProvider.isShellSupported(anything())).thenReturn(true); - [bashActivationProvider, cmdActivationProvider, pyenvActivationProvider].forEach((provider) => { + [ + bashActivationProvider, + cmdActivationProvider, + nushellActivationProvider, + pyenvActivationProvider, + ].forEach((provider) => { when(provider.getActivationCommands(resource, anything())).thenResolve(['Something']); when(provider.isShellSupported(anything())).thenReturn(true); }); @@ -278,6 +291,7 @@ suite('Terminal Service helpers', () => { verify(pipenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(pipenvActivationProvider.getActivationCommands(resource, anything())).atLeast(1); verify(cmdActivationProvider.isShellSupported(anything())).atLeast(1); + verify(nushellActivationProvider.isShellSupported(anything())).atLeast(1); }); test('Activation command must return command from Command Prompt if that is supported and others are not', async () => { const pythonPath = 'some python Path value'; @@ -288,6 +302,7 @@ suite('Terminal Service helpers', () => { when(bashActivationProvider.isShellSupported(anything())).thenReturn(false); when(cmdActivationProvider.isShellSupported(anything())).thenReturn(true); + when(nushellActivationProvider.isShellSupported(anything())).thenReturn(false); when(pyenvActivationProvider.isShellSupported(anything())).thenReturn(false); when(pipenvActivationProvider.isShellSupported(anything())).thenReturn(false); @@ -297,21 +312,24 @@ suite('Terminal Service helpers', () => { verify(pythonSettings.pythonPath).once(); verify(condaService.isCondaEnvironment(pythonPath)).once(); verify(bashActivationProvider.isShellSupported(anything())).atLeast(1); + verify(nushellActivationProvider.isShellSupported(anything())).atLeast(1); verify(cmdActivationProvider.getActivationCommands(resource, anything())).once(); verify(pyenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(pipenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(cmdActivationProvider.isShellSupported(anything())).atLeast(1); }); - test('Activation command must return command from Command Prompt if that is supported, and so is bash but no commands are returned', async () => { + test('Activation command must return command from Command Prompt if that is supported, and so is bash and nushell but no commands are returned', async () => { const pythonPath = 'some python Path value'; const expectCommand = ['one', 'two']; ensureCondaIsSupported(false, pythonPath, []); when(cmdActivationProvider.getActivationCommands(resource, anything())).thenResolve(expectCommand); when(bashActivationProvider.getActivationCommands(resource, anything())).thenResolve([]); + when(nushellActivationProvider.getActivationCommands(resource, anything())).thenResolve([]); when(bashActivationProvider.isShellSupported(anything())).thenReturn(true); when(cmdActivationProvider.isShellSupported(anything())).thenReturn(true); + when(nushellActivationProvider.isShellSupported(anything())).thenReturn(true); when(pyenvActivationProvider.isShellSupported(anything())).thenReturn(false); when(pipenvActivationProvider.isShellSupported(anything())).thenReturn(false); @@ -320,12 +338,15 @@ suite('Terminal Service helpers', () => { expect(cmd).to.deep.equal(expectCommand); verify(pythonSettings.pythonPath).once(); verify(condaService.isCondaEnvironment(pythonPath)).once(); - verify(bashActivationProvider.isShellSupported(anything())).atLeast(1); verify(bashActivationProvider.getActivationCommands(resource, anything())).once(); verify(cmdActivationProvider.getActivationCommands(resource, anything())).once(); + // It should not be called as command prompt already returns the activation commands and is higher priority. + verify(nushellActivationProvider.getActivationCommands(resource, anything())).never(); verify(pyenvActivationProvider.isShellSupported(anything())).atLeast(1); verify(pipenvActivationProvider.isShellSupported(anything())).atLeast(1); + verify(bashActivationProvider.isShellSupported(anything())).atLeast(1); verify(cmdActivationProvider.isShellSupported(anything())).atLeast(1); + verify(nushellActivationProvider.isShellSupported(anything())).atLeast(1); }); [undefined, pythonInterpreter].forEach((interpreter) => { test('Activation command for Shell must be empty for unknown os', async () => { @@ -353,6 +374,7 @@ suite('Terminal Service helpers', () => { when(platformService.osType).thenReturn(osType); when(bashActivationProvider.isShellSupported(shellToExpect)).thenReturn(false); when(cmdActivationProvider.isShellSupported(shellToExpect)).thenReturn(false); + when(nushellActivationProvider.isShellSupported(shellToExpect)).thenReturn(false); const cmd = await helper.getEnvironmentActivationShellCommands( resource, @@ -367,6 +389,7 @@ suite('Terminal Service helpers', () => { verify(pyenvActivationProvider.isShellSupported(anything())).never(); verify(pipenvActivationProvider.isShellSupported(anything())).never(); verify(cmdActivationProvider.isShellSupported(shellToExpect)).atLeast(1); + verify(nushellActivationProvider.isShellSupported(shellToExpect)).atLeast(1); }); }); }); diff --git a/extensions/positron-python/src/test/common/terminals/shellDetector.unit.test.ts b/extensions/positron-python/src/test/common/terminals/shellDetector.unit.test.ts index 7985cf06498..c09560a3ea3 100644 --- a/extensions/positron-python/src/test/common/terminals/shellDetector.unit.test.ts +++ b/extensions/positron-python/src/test/common/terminals/shellDetector.unit.test.ts @@ -34,7 +34,7 @@ suite('Shell Detector', () => { getNamesAndValues(OSType).forEach((os) => { const testSuffix = `(OS ${os.name})`; - test('Test identification of Terminal Shells in order of priority', async () => { + test(`Test identification of Terminal Shells in order of priority ${testSuffix}`, async () => { const callOrder: string[] = []; const nameDetectorIdentify = sandbox.stub(TerminalNameShellDetector.prototype, 'identify'); nameDetectorIdentify.callsFake(() => { diff --git a/extensions/positron-python/src/test/common/utils/exec.unit.test.ts b/extensions/positron-python/src/test/common/utils/exec.unit.test.ts new file mode 100644 index 00000000000..aebfbe7a417 --- /dev/null +++ b/extensions/positron-python/src/test/common/utils/exec.unit.test.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import { OSType } from '../../common'; +import { getSearchPathEnvVarNames } from '../../../client/common/utils/exec'; + +suite('Utils for exec - getSearchPathEnvVarNames function', () => { + const testsData = [ + { os: 'Unknown', expected: ['PATH'] }, + { os: 'Windows', expected: ['Path', 'PATH'] }, + { os: 'OSX', expected: ['PATH'] }, + { os: 'Linux', expected: ['PATH'] }, + ]; + + testsData.forEach((testData) => { + test(`getSearchPathEnvVarNames when os is ${testData.os}`, () => { + const pathVariables = getSearchPathEnvVarNames(testData.os as OSType); + + expect(pathVariables).to.deep.equal(testData.expected); + }); + }); +}); diff --git a/extensions/positron-python/src/test/common/utils/filesystem.unit.test.ts b/extensions/positron-python/src/test/common/utils/filesystem.unit.test.ts new file mode 100644 index 00000000000..a1c53edc73e --- /dev/null +++ b/extensions/positron-python/src/test/common/utils/filesystem.unit.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import { convertFileType } from '../../../client/common/utils/filesystem'; + +class KnowsFileTypeDummyImpl { + private _isFile: boolean; + + private _isDirectory: boolean; + + private _isSymbolicLink: boolean; + + constructor(isFile = false, isDirectory = false, isSymbolicLink = false) { + this._isFile = isFile; + this._isDirectory = isDirectory; + this._isSymbolicLink = isSymbolicLink; + } + + public isFile() { + return this._isFile; + } + + public isDirectory() { + return this._isDirectory; + } + + public isSymbolicLink() { + return this._isSymbolicLink; + } +} + +suite('Utils for filesystem - convertFileType function', () => { + const testsData = [ + { info: new KnowsFileTypeDummyImpl(true, false, false), kind: 'File', expected: 1 }, + { info: new KnowsFileTypeDummyImpl(false, true, false), kind: 'Directory', expected: 2 }, + { info: new KnowsFileTypeDummyImpl(false, false, true), kind: 'Symbolic Link', expected: 64 }, + { info: new KnowsFileTypeDummyImpl(false, false, false), kind: 'Unknown', expected: 0 }, + ]; + + testsData.forEach((testData) => { + test(`convertFileType when info is a ${testData.kind}`, () => { + const fileType = convertFileType(testData.info); + + expect(fileType).equals(testData.expected); + }); + }); +}); diff --git a/extensions/positron-python/src/test/common/utils/platform.unit.test.ts b/extensions/positron-python/src/test/common/utils/platform.unit.test.ts new file mode 100644 index 00000000000..b27708978fc --- /dev/null +++ b/extensions/positron-python/src/test/common/utils/platform.unit.test.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import { OSType, getOSType } from '../../../client/common/utils/platform'; + +suite('Utils for platform - getOSType function', () => { + const testsData = [ + { platform: 'linux', expected: OSType.Linux }, + { platform: 'darwin', expected: OSType.OSX }, + { platform: 'anunknownplatform', expected: OSType.Unknown }, + { platform: 'windows', expected: OSType.Windows }, + ]; + + testsData.forEach((testData) => { + test(`getOSType when platform is ${testData.platform}`, () => { + const osType = getOSType(testData.platform); + expect(osType).equal(testData.expected); + }); + }); +}); diff --git a/extensions/positron-python/src/test/common/utils/text.unit.test.ts b/extensions/positron-python/src/test/common/utils/text.unit.test.ts index f15816c5159..7e7a22896e9 100644 --- a/extensions/positron-python/src/test/common/utils/text.unit.test.ts +++ b/extensions/positron-python/src/test/common/utils/text.unit.test.ts @@ -5,7 +5,7 @@ import { expect } from 'chai'; import { Position, Range } from 'vscode'; -import { parsePosition, parseRange } from '../../../client/common/utils/text'; +import { getDedentedLines, getIndent, parsePosition, parseRange } from '../../../client/common/utils/text'; suite('parseRange()', () => { test('valid strings', async () => { @@ -98,3 +98,52 @@ suite('parsePosition()', () => { } }); }); + +suite('getIndent()', () => { + const testsData = [ + { line: 'text', expected: '' }, + { line: ' text', expected: ' ' }, + { line: ' text', expected: ' ' }, + { line: ' tabulatedtext', expected: '' }, + ]; + + testsData.forEach((testData) => { + test(`getIndent when line is ${testData.line}`, () => { + const indent = getIndent(testData.line); + + expect(indent).equal(testData.expected); + }); + }); +}); + +suite('getDedentedLines()', () => { + const testsData = [ + { text: '', expected: [] }, + { text: '\n', expected: Error, exceptionMessage: 'expected "first" line to not be blank' }, + { text: 'line1\n', expected: Error, exceptionMessage: 'expected actual first line to be blank' }, + { + text: '\n line2\n line3', + expected: Error, + exceptionMessage: 'line 1 has less indent than the "first" line', + }, + { + text: '\n line2\n line3', + expected: ['line2', 'line3'], + }, + { + text: '\n line2\n line3', + expected: ['line2', ' line3'], + }, + ]; + + testsData.forEach((testData) => { + test(`getDedentedLines when line is ${testData.text}`, () => { + if (Array.isArray(testData.expected)) { + const dedentedLines = getDedentedLines(testData.text); + expect(dedentedLines).to.deep.equal(testData.expected); + } else { + expect(() => getDedentedLines(testData.text)).to.throw(testData.expected, testData.exceptionMessage); + } + }); + }); +}); diff --git a/extensions/positron-python/src/test/common/variables/envVarsService.unit.test.ts b/extensions/positron-python/src/test/common/variables/envVarsService.unit.test.ts index 590803ea135..0c978b2f9e8 100644 --- a/extensions/positron-python/src/test/common/variables/envVarsService.unit.test.ts +++ b/extensions/positron-python/src/test/common/variables/envVarsService.unit.test.ts @@ -10,17 +10,16 @@ import * as TypeMoq from 'typemoq'; import { IFileSystem } from '../../../client/common/platform/types'; import { IPathUtils } from '../../../client/common/types'; import { EnvironmentVariablesService, parseEnvFile } from '../../../client/common/variables/environment'; +import { getSearchPathEnvVarNames } from '../../../client/common/utils/exec'; use(chaiAsPromised); type PathVar = 'Path' | 'PATH'; -const PATHS = [ - 'Path', // Windows - 'PATH', // non-Windows -]; +const PATHS = getSearchPathEnvVarNames(); suite('Environment Variables Service', () => { const filename = 'x/y/z/.env'; + const processEnvPath = getSearchPathEnvVarNames()[0]; let pathUtils: TypeMoq.IMock; let fs: TypeMoq.IMock; let variablesService: EnvironmentVariablesService; @@ -208,7 +207,7 @@ PYTHON=${BINDIR}/python3\n\ expect(vars2).to.have.property('TWO', 'TWO', 'Incorrect value'); expect(vars2).to.have.property('THREE', '3', 'Variable not merged'); expect(vars2).to.have.property('PYTHONPATH', 'PYTHONPATH', 'Incorrect value'); - expect(vars2).to.have.property(pathVariable, 'PATH', 'Incorrect value'); + expect(vars2).to.have.property(processEnvPath, 'PATH', 'Incorrect value'); verifyAll(); }); @@ -226,7 +225,7 @@ PYTHON=${BINDIR}/python3\n\ expect(target).to.have.property('TWO', 'TWO', 'Incorrect value'); expect(target).to.have.property('THREE', '3', 'Variable not merged'); expect(target).to.have.property('PYTHONPATH', 'PYTHONPATH', 'Incorrect value'); - expect(target).to.have.property(pathVariable, 'PATH', 'Incorrect value'); + expect(target).to.have.property(processEnvPath, 'PATH', 'Incorrect value'); verifyAll(); }); }); @@ -266,17 +265,17 @@ PYTHON=${BINDIR}/python3\n\ variablesService.appendPath(vars); expect(Object.keys(vars)).lengthOf(2, 'Incorrect number of variables'); expect(vars).to.have.property('ONE', '1', 'Incorrect value'); - expect(vars).to.have.property(pathVariable, 'PATH', 'Incorrect value'); + expect(vars).to.have.property(processEnvPath, 'PATH', 'Incorrect value'); variablesService.appendPath(vars, ''); expect(Object.keys(vars)).lengthOf(2, 'Incorrect number of variables'); expect(vars).to.have.property('ONE', '1', 'Incorrect value'); - expect(vars).to.have.property(pathVariable, 'PATH', 'Incorrect value'); + expect(vars).to.have.property(processEnvPath, 'PATH', 'Incorrect value'); variablesService.appendPath(vars, ' ', ''); expect(Object.keys(vars)).lengthOf(2, 'Incorrect number of variables'); expect(vars).to.have.property('ONE', '1', 'Incorrect value'); - expect(vars).to.have.property(pathVariable, 'PATH', 'Incorrect value'); + expect(vars).to.have.property(processEnvPath, 'PATH', 'Incorrect value'); verifyAll(); }); @@ -291,7 +290,11 @@ PYTHON=${BINDIR}/python3\n\ expect(Object.keys(vars)).lengthOf(2, 'Incorrect number of variables'); expect(vars).to.have.property('ONE', '1', 'Incorrect value'); - expect(vars).to.have.property(pathVariable, `PATH${path.delimiter}${pathToAppend}`, 'Incorrect value'); + expect(vars).to.have.property( + processEnvPath, + `PATH${path.delimiter}${pathToAppend}`, + 'Incorrect value', + ); verifyAll(); }); }); diff --git a/extensions/positron-python/src/test/debugger/envVars.test.ts b/extensions/positron-python/src/test/debugger/envVars.test.ts index 6aa0dea4d8c..c043146fe53 100644 --- a/extensions/positron-python/src/test/debugger/envVars.test.ts +++ b/extensions/positron-python/src/test/debugger/envVars.test.ts @@ -15,6 +15,7 @@ import { ConsoleType, LaunchRequestArguments } from '../../client/debugger/types import { isOs, OSType } from '../common'; import { closeActiveWindows, initialize, initializeTest, IS_MULTI_ROOT_TEST, TEST_DEBUGGER } from '../initialize'; import { UnitTestIocContainer } from '../testing/serviceRegistry'; +import { normCase } from '../../client/common/platform/fs-paths'; use(chaiAsPromised); @@ -109,9 +110,9 @@ suite('Resolving Environment Variables when Debugging', () => { }); async function testJsonEnvVariables(console: ConsoleType, expectedNumberOfVariables: number) { - const prop1 = shortid.generate(); - const prop2 = shortid.generate(); - const prop3 = shortid.generate(); + const prop1 = normCase(shortid.generate()); + const prop2 = normCase(shortid.generate()); + const prop3 = normCase(shortid.generate()); const env: Record = {}; env[prop1] = prop1; env[prop2] = prop2; diff --git a/extensions/positron-python/src/test/format/formatter.unit.test.ts b/extensions/positron-python/src/test/format/formatter.unit.test.ts index cf0297628a8..05970d0c71f 100644 --- a/extensions/positron-python/src/test/format/formatter.unit.test.ts +++ b/extensions/positron-python/src/test/format/formatter.unit.test.ts @@ -13,7 +13,6 @@ import { IApplicationShell, IWorkspaceService } from '../../client/common/applic import { WorkspaceService } from '../../client/common/application/workspace'; import { PythonSettings } from '../../client/common/configSettings'; import { ConfigurationService } from '../../client/common/configuration/service'; -import { STANDARD_OUTPUT_CHANNEL } from '../../client/common/constants'; import { PythonToolExecutionService } from '../../client/common/process/pythonToolService'; import { IPythonToolExecutionService } from '../../client/common/process/types'; import { @@ -21,7 +20,7 @@ import { IConfigurationService, IDisposableRegistry, IFormattingSettings, - IOutputChannel, + ILogOutputChannel, IPythonSettings, } from '../../client/common/types'; import { AutoPep8Formatter } from '../../client/formatters/autoPep8Formatter'; @@ -37,7 +36,7 @@ import { MockOutputChannel } from '../mockClasses'; suite('Formatting - Test Arguments', () => { let container: IServiceContainer; - let outputChannel: IOutputChannel; + let outputChannel: ILogOutputChannel; let workspace: IWorkspaceService; let settings: IPythonSettings; const workspaceUri = Uri.file(__dirname); @@ -85,9 +84,7 @@ suite('Formatting - Test Arguments', () => { when(configService.getSettings(anything())).thenReturn(instance(settings)); when(workspace.getWorkspaceFolder(anything())).thenReturn({ name: '', index: 0, uri: workspaceUri }); - when(container.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL)).thenReturn( - instance(outputChannel), - ); + when(container.get(ILogOutputChannel)).thenReturn(instance(outputChannel)); when(container.get(IApplicationShell)).thenReturn(instance(appShell)); when(container.get(IFormatterHelper)).thenReturn(formatterHelper); when(container.get(IWorkspaceService)).thenReturn(instance(workspace)); diff --git a/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts b/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts index 002189d412d..9b2c121c89b 100644 --- a/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts +++ b/extensions/positron-python/src/test/interpreters/activation/service.unit.test.ts @@ -28,6 +28,7 @@ import { EnvironmentActivationService } from '../../../client/interpreter/activa import { IInterpreterService } from '../../../client/interpreter/contracts'; import { InterpreterService } from '../../../client/interpreter/interpreterService'; import { EnvironmentType, PythonEnvironment } from '../../../client/pythonEnvironments/info'; +import { getSearchPathEnvVarNames } from '../../../client/common/utils/exec'; const getEnvironmentPrefix = 'e8b39361-0157-4923-80e1-22d70d46dee6'; const defaultShells = { @@ -118,6 +119,25 @@ suite('Interpreters Activation - Python Environment Variables', () => { helper.getEnvironmentActivationShellCommands(resource, anything(), interpreter), ).once(); }); + test('Env variables returned for microvenv', async () => { + when(platform.osType).thenReturn(osType.value); + + const microVenv = { ...pythonInterpreter, envType: EnvironmentType.Venv }; + const key = getSearchPathEnvVarNames()[0]; + const varsFromEnv = { [key]: '/foo/bar' }; + + when( + helper.getEnvironmentActivationShellCommands(resource, anything(), microVenv), + ).thenResolve(); + + const env = await service.getActivatedEnvironmentVariables(resource, microVenv); + + verify(platform.osType).once(); + expect(env).to.deep.equal(varsFromEnv); + verify( + helper.getEnvironmentActivationShellCommands(resource, anything(), microVenv), + ).once(); + }); test('Validate command used to activation and printing env vars', async () => { const cmd = ['1', '2']; const envVars = { one: '1', two: '2' }; 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 4ac04cf1ee2..feecf63f557 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 @@ -7,13 +7,23 @@ 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, ProgressLocation, Uri } from 'vscode'; -import { IApplicationShell, IApplicationEnvironment } from '../../../client/common/application/types'; +import { EnvironmentVariableCollection, ProgressLocation, Uri, WorkspaceFolder } from 'vscode'; +import { + IApplicationShell, + IApplicationEnvironment, + IWorkspaceService, +} from '../../../client/common/application/types'; import { TerminalEnvVarActivation } from '../../../client/common/experiments/groups'; import { IPlatformService } from '../../../client/common/platform/types'; -import { IExtensionContext, IExperimentService, Resource } from '../../../client/common/types'; +import { + IExtensionContext, + IExperimentService, + Resource, + IConfigurationService, + IPythonSettings, +} from '../../../client/common/types'; import { Interpreters } from '../../../client/common/utils/localize'; -import { getOSType } from '../../../client/common/utils/platform'; +import { OSType, getOSType } from '../../../client/common/utils/platform'; import { defaultShells } from '../../../client/interpreter/activation/service'; import { TerminalEnvVarCollectionService, @@ -21,6 +31,7 @@ import { } 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'; suite('Terminal Environment Variable Collection Service', () => { let platform: IPlatformService; @@ -31,15 +42,21 @@ suite('Terminal Environment Variable Collection Service', () => { let collection: EnvironmentVariableCollection; let applicationEnvironment: IApplicationEnvironment; let environmentActivationService: IEnvironmentActivationService; + let workspaceService: IWorkspaceService; let terminalEnvVarCollectionService: TerminalEnvVarCollectionService; const progressOptions = { location: ProgressLocation.Window, title: Interpreters.activatingTerminals, }; + let configService: IConfigurationService; + const displayPath = 'display/path'; const customShell = 'powershell'; const defaultShell = defaultShells[getOSType()]; setup(() => { + workspaceService = mock(); + when(workspaceService.getWorkspaceFolder(anything())).thenReturn(undefined); + when(workspaceService.workspaceFolders).thenReturn(undefined); platform = mock(); when(platform.osType).thenReturn(getOSType()); interpreterService = mock(); @@ -57,6 +74,11 @@ suite('Terminal Environment Variable Collection Service', () => { }) .thenResolve(); environmentActivationService = mock(); + configService = mock(); + when(configService.getSettings(anything())).thenReturn(({ + terminal: { activateEnvironment: true }, + pythonPath: displayPath, + } as unknown) as IPythonSettings); terminalEnvVarCollectionService = new TerminalEnvVarCollectionService( instance(platform), instance(interpreterService), @@ -66,6 +88,9 @@ suite('Terminal Environment Variable Collection Service', () => { instance(applicationEnvironment), [], instance(environmentActivationService), + instance(workspaceService), + instance(configService), + new PathUtils(getOSType() === OSType.Windows), ); }); @@ -78,7 +103,7 @@ suite('Terminal Environment Variable Collection Service', () => { applyCollectionStub.resolves(); when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenReturn(); when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenReturn(); - await terminalEnvVarCollectionService.activate(); + await terminalEnvVarCollectionService.activate(undefined); assert(applyCollectionStub.calledOnce, 'Collection not applied on activation'); }); @@ -90,13 +115,13 @@ suite('Terminal Environment Variable Collection Service', () => { when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenReturn(); when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenReturn(); - await terminalEnvVarCollectionService.activate(); + await terminalEnvVarCollectionService.activate(undefined); - verify(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).never(); + verify(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).once(); verify(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).never(); assert(applyCollectionStub.notCalled, 'Collection should not be applied on activation'); - verify(collection.clear()).once(); + verify(collection.clear()).atLeast(1); }); test('When interpreter changes, apply new activated variables to the collection', async () => { @@ -108,7 +133,7 @@ suite('Terminal Environment Variable Collection Service', () => { callback = cb; }); when(applicationEnvironment.onDidChangeShell(anything(), anything(), anything())).thenReturn(); - await terminalEnvVarCollectionService.activate(); + await terminalEnvVarCollectionService.activate(undefined); await callback!(resource); assert(applyCollectionStub.calledWithExactly(resource)); @@ -122,7 +147,7 @@ suite('Terminal Environment Variable Collection Service', () => { callback = cb; }); when(interpreterService.onDidChangeInterpreter(anything(), anything(), anything())).thenReturn(); - await terminalEnvVarCollectionService.activate(); + await terminalEnvVarCollectionService.activate(undefined); await callback!(customShell); assert(applyCollectionStub.calledWithExactly(undefined, customShell)); @@ -140,13 +165,74 @@ suite('Terminal Environment Variable Collection Service', () => { ), ).thenResolve(envVars); - when(collection.replace(anything(), anything())).thenResolve(); - when(collection.delete(anything())).thenResolve(); + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything(), anything())).thenResolve(); await terminalEnvVarCollectionService._applyCollection(undefined, customShell); - verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda')).once(); - verify(collection.delete('PATH')).once(); + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); + verify(collection.delete('PATH', anything())).once(); + }); + + 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; + when( + environmentActivationService.getActivatedEnvironmentVariables( + anything(), + undefined, + undefined, + customShell, + ), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything(), anything())).thenResolve(); + reset(configService); + when(configService.getSettings(anything())).thenReturn(({ + terminal: { activateEnvironment: false }, + pythonPath: displayPath, + } as unknown) as IPythonSettings); + + await terminalEnvVarCollectionService._applyCollection(undefined, customShell); + + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).never(); + verify(collection.delete('PATH', anything())).never(); + }); + + test('Verify correct scope is used when applying envs and setting description', async () => { + const envVars: NodeJS.ProcessEnv = { CONDA_PREFIX: 'prefix/to/conda', ..._normCaseKeys(process.env) }; + delete envVars.PATH; + const resource = Uri.file('a'); + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file('workspacePath'), + name: 'workspace1', + index: 0, + }; + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + when( + environmentActivationService.getActivatedEnvironmentVariables(resource, undefined, undefined, customShell), + ).thenResolve(envVars); + + when(collection.replace(anything(), anything(), anything())).thenCall((_e, _v, scope) => { + assert.deepEqual(scope, { workspaceFolder }); + return Promise.resolve(); + }); + when(collection.delete(anything(), anything())).thenCall((_e, scope) => { + assert.deepEqual(scope, { workspaceFolder }); + return Promise.resolve(); + }); + let description = ''; + when(collection.setDescription(anything(), anything())).thenCall((d, scope) => { + assert.deepEqual(scope, { workspaceFolder }); + description = d.value; + }); + + await terminalEnvVarCollectionService._applyCollection(resource, customShell); + + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); + verify(collection.delete('PATH', anything())).once(); + expect(description).to.equal(`${Interpreters.activateTerminalDescription} \`${displayPath}\``); }); test('Only relative changes to previously applied variables are applied to the collection', async () => { @@ -164,8 +250,8 @@ suite('Terminal Environment Variable Collection Service', () => { ), ).thenResolve(envVars); - when(collection.replace(anything(), anything())).thenResolve(); - when(collection.delete(anything())).thenResolve(); + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything(), anything())).thenResolve(); await terminalEnvVarCollectionService._applyCollection(undefined, customShell); @@ -184,8 +270,8 @@ suite('Terminal Environment Variable Collection Service', () => { await terminalEnvVarCollectionService._applyCollection(undefined, customShell); - verify(collection.delete('CONDA_PREFIX')).once(); - verify(collection.delete('RANDOM_VAR')).once(); + verify(collection.delete('CONDA_PREFIX', anything())).once(); + verify(collection.delete('RANDOM_VAR', anything())).once(); }); test('If no activated variables are returned for custom shell, fallback to using default shell', async () => { @@ -207,13 +293,13 @@ suite('Terminal Environment Variable Collection Service', () => { ), ).thenResolve(envVars); - when(collection.replace(anything(), anything())).thenResolve(); - when(collection.delete(anything())).thenResolve(); + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything(), anything())).thenResolve(); await terminalEnvVarCollectionService._applyCollection(undefined, customShell); - verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda')).once(); - verify(collection.delete(anything())).never(); + verify(collection.replace('CONDA_PREFIX', 'prefix/to/conda', anything())).once(); + verify(collection.delete(anything(), anything())).never(); }); test('If no activated variables are returned for default shell, clear collection', async () => { @@ -226,11 +312,13 @@ suite('Terminal Environment Variable Collection Service', () => { ), ).thenResolve(undefined); - when(collection.replace(anything(), anything())).thenResolve(); - when(collection.delete(anything())).thenResolve(); + when(collection.replace(anything(), anything(), anything())).thenResolve(); + when(collection.delete(anything(), anything())).thenResolve(); + when(collection.setDescription(anything(), anything())).thenReturn(); await terminalEnvVarCollectionService._applyCollection(undefined, defaultShell?.shell); - verify(collection.clear()).once(); + verify(collection.clear(anything())).once(); + verify(collection.setDescription(anything(), anything())).never(); }); }); diff --git a/extensions/positron-python/src/test/jupyter/requireJupyterPrompt.unit.test.ts b/extensions/positron-python/src/test/jupyter/requireJupyterPrompt.unit.test.ts new file mode 100644 index 00000000000..0eb6c9e0695 --- /dev/null +++ b/extensions/positron-python/src/test/jupyter/requireJupyterPrompt.unit.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { mock, instance, verify, anything, when } from 'ts-mockito'; +import { IApplicationShell, ICommandManager } from '../../client/common/application/types'; +import { Commands, JUPYTER_EXTENSION_ID } from '../../client/common/constants'; +import { IDisposableRegistry } from '../../client/common/types'; +import { Common, Interpreters } from '../../client/common/utils/localize'; +import { RequireJupyterPrompt } from '../../client/jupyter/requireJupyterPrompt'; + +suite('RequireJupyterPrompt Unit Tests', () => { + let requireJupyterPrompt: RequireJupyterPrompt; + let appShell: IApplicationShell; + let commandManager: ICommandManager; + let disposables: IDisposableRegistry; + + setup(() => { + appShell = mock(); + commandManager = mock(); + disposables = mock(); + + requireJupyterPrompt = new RequireJupyterPrompt( + instance(appShell), + instance(commandManager), + instance(disposables), + ); + }); + + test('Activation registers command', async () => { + await requireJupyterPrompt.activate(); + + verify(commandManager.registerCommand(Commands.InstallJupyter, anything())).once(); + }); + + test('Show prompt with Yes selection installs Jupyter extension', async () => { + when( + appShell.showInformationMessage(Interpreters.requireJupyter, Common.bannerLabelYes, Common.bannerLabelNo), + ).thenReturn(Promise.resolve(Common.bannerLabelYes)); + + await requireJupyterPrompt.activate(); + await requireJupyterPrompt._showPrompt(); + + verify( + commandManager.executeCommand('workbench.extensions.installExtension', JUPYTER_EXTENSION_ID, undefined), + ).once(); + }); + + test('Show prompt with No selection does not install Jupyter extension', async () => { + when( + appShell.showInformationMessage(Interpreters.requireJupyter, Common.bannerLabelYes, Common.bannerLabelNo), + ).thenReturn(Promise.resolve(Common.bannerLabelNo)); + + await requireJupyterPrompt.activate(); + await requireJupyterPrompt._showPrompt(); + + verify( + commandManager.executeCommand('workbench.extensions.installExtension', JUPYTER_EXTENSION_ID, undefined), + ).never(); + }); +}); diff --git a/extensions/positron-python/src/test/linters/common.ts b/extensions/positron-python/src/test/linters/common.ts index c602492ccd6..3c8f72a8d71 100644 --- a/extensions/positron-python/src/test/linters/common.ts +++ b/extensions/positron-python/src/test/linters/common.ts @@ -18,7 +18,7 @@ import { IConfigurationService, IInstaller, IMypyCategorySeverity, - IOutputChannel, + ILogOutputChannel, IPycodestyleCategorySeverity, IPylintCategorySeverity, IPythonSettings, @@ -243,7 +243,7 @@ export class BaseTestFixture { public lintingSettings: LintingSettings; // data - public outputChannel: TypeMoq.IMock; + public outputChannel: TypeMoq.IMock; // artifacts public output: string; @@ -309,10 +309,10 @@ export class BaseTestFixture { // data - this.outputChannel = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); + this.outputChannel = TypeMoq.Mock.ofType(undefined, TypeMoq.MockBehavior.Strict); this.serviceContainer - .setup((c) => c.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isAny())) + .setup((c) => c.get(TypeMoq.It.isValue(ILogOutputChannel))) .returns(() => this.outputChannel.object); this.initData(); diff --git a/extensions/positron-python/src/test/linters/lintengine.test.ts b/extensions/positron-python/src/test/linters/lintengine.test.ts index 20599d7fb71..1bf77c502af 100644 --- a/extensions/positron-python/src/test/linters/lintengine.test.ts +++ b/extensions/positron-python/src/test/linters/lintengine.test.ts @@ -4,12 +4,12 @@ 'use strict'; import * as TypeMoq from 'typemoq'; -import { OutputChannel, TextDocument, Uri } from 'vscode'; +import { TextDocument, Uri } from 'vscode'; import { IDocumentManager, IWorkspaceService } from '../../client/common/application/types'; -import { PYTHON_LANGUAGE, STANDARD_OUTPUT_CHANNEL } from '../../client/common/constants'; +import { PYTHON_LANGUAGE } from '../../client/common/constants'; import '../../client/common/extensions'; import { IFileSystem } from '../../client/common/platform/types'; -import { IConfigurationService, ILintingSettings, IOutputChannel, IPythonSettings } from '../../client/common/types'; +import { IConfigurationService, ILintingSettings, ILogOutputChannel, IPythonSettings } from '../../client/common/types'; import { IInterpreterService } from '../../client/interpreter/contracts'; import { IServiceContainer } from '../../client/ioc/types'; import { LintingEngine } from '../../client/linters/lintingEngine'; @@ -54,10 +54,8 @@ suite('Linting - LintingEngine', () => { .setup((c) => c.get(TypeMoq.It.isValue(IConfigurationService), TypeMoq.It.isAny())) .returns(() => configService.object); - const outputChannel = TypeMoq.Mock.ofType(); - serviceContainer - .setup((c) => c.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isValue(STANDARD_OUTPUT_CHANNEL))) - .returns(() => outputChannel.object); + const outputChannel = TypeMoq.Mock.ofType(); + serviceContainer.setup((c) => c.get(TypeMoq.It.isValue(ILogOutputChannel))).returns(() => outputChannel.object); lintManager = TypeMoq.Mock.ofType(); lintManager.setup((x) => x.isLintingEnabled(TypeMoq.It.isAny())).returns(async () => true); diff --git a/extensions/positron-python/src/test/mockClasses.ts b/extensions/positron-python/src/test/mockClasses.ts index 0273cf27fdb..c962c4d67ca 100644 --- a/extensions/positron-python/src/test/mockClasses.ts +++ b/extensions/positron-python/src/test/mockClasses.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import * as util from 'util'; import { Flake8CategorySeverity, ILintingSettings, @@ -7,13 +8,32 @@ import { IPylintCategorySeverity, } from '../client/common/types'; -export class MockOutputChannel implements vscode.OutputChannel { +export class MockOutputChannel implements vscode.LogOutputChannel { public name: string; public output: string; public isShown!: boolean; + private _eventEmitter = new vscode.EventEmitter(); + public onDidChangeLogLevel: vscode.Event = this._eventEmitter.event; constructor(name: string) { this.name = name; this.output = ''; + this.logLevel = vscode.LogLevel.Debug; + } + public logLevel: vscode.LogLevel; + trace(message: string, ...args: any[]): void { + this.appendLine(util.format(message, ...args)); + } + debug(message: string, ...args: any[]): void { + this.appendLine(util.format(message, ...args)); + } + info(message: string, ...args: any[]): void { + this.appendLine(util.format(message, ...args)); + } + warn(message: string, ...args: any[]): void { + this.appendLine(util.format(message, ...args)); + } + error(error: string | Error, ...args: any[]): void { + this.appendLine(util.format(error, ...args)); } public append(value: string) { this.output += value; diff --git a/extensions/positron-python/src/test/mocks/vsc/index.ts b/extensions/positron-python/src/test/mocks/vsc/index.ts index e6ea57a8867..89f4ab1a2d0 100644 --- a/extensions/positron-python/src/test/mocks/vsc/index.ts +++ b/extensions/positron-python/src/test/mocks/vsc/index.ts @@ -411,3 +411,35 @@ export class InlayHint { public kind?: vscode.InlayHintKind, ) {} } + +export enum LogLevel { + /** + * No messages are logged with this level. + */ + Off = 0, + + /** + * All messages are logged with this level. + */ + Trace = 1, + + /** + * Messages with debug and higher log level are logged with this level. + */ + Debug = 2, + + /** + * Messages with info and higher log level are logged with this level. + */ + Info = 3, + + /** + * Messages with warning and higher log level are logged with this level. + */ + Warning = 4, + + /** + * Only error messages are logged with this level. + */ + Error = 5, +} diff --git a/extensions/positron-python/src/test/proc.ts b/extensions/positron-python/src/test/proc.ts index a25ae1aebfc..8a21eb379f7 100644 --- a/extensions/positron-python/src/test/proc.ts +++ b/extensions/positron-python/src/test/proc.ts @@ -54,7 +54,7 @@ export class Proc { this.raw = (raw as unknown) as IRawProc; this.output = output; } - public get pid(): number { + public get pid(): number | undefined { return this.raw.pid; } public get exited(): boolean { diff --git a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/poetryLocator.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/poetryLocator.unit.test.ts index 1f30aff26f0..95c1a401df5 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/poetryLocator.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/base/locators/lowLevel/poetryLocator.unit.test.ts @@ -36,7 +36,8 @@ suite('Poetry Locator', () => { getOSTypeStub.returns(platformUtils.OSType.Windows); shellExecute.callsFake((command: string, options: ShellOptions) => { if (command === 'poetry env list --full-path') { - if (options.cwd && externalDependencies.arePathsSame(options.cwd, project1)) { + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); + if (cwd && externalDependencies.arePathsSame(cwd, project1)) { return Promise.resolve>({ stdout: `${path.join(testPoetryDir, 'poetry-tutorial-project-6hnqYwvD-py3.8')} \n ${path.join(testPoetryDir, 'globalwinproject-9hvDnqYw-py3.11')} (Activated)\r\n @@ -76,7 +77,8 @@ suite('Poetry Locator', () => { getOSTypeStub.returns(platformUtils.OSType.Linux); shellExecute.callsFake((command: string, options: ShellOptions) => { if (command === 'poetry env list --full-path') { - if (options.cwd && externalDependencies.arePathsSame(options.cwd, project2)) { + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); + if (cwd && externalDependencies.arePathsSame(cwd, project2)) { return Promise.resolve>({ stdout: `${path.join(testPoetryDir, 'posix1project-9hvDnqYw-py3.4')} (Activated)\n ${path.join(testPoetryDir, 'posix2project-6hnqYwvD-py3.7')}`, diff --git a/extensions/positron-python/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts index 166b388a11c..5e40e3454e2 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/common/environmentManagers/poetry.unit.test.ts @@ -123,10 +123,11 @@ suite('Poetry binary is located correctly', async () => { test('When user has specified a valid poetry path, use it', async () => { getPythonSetting.returns('poetryPath'); shellExecute.callsFake((command: string, options: ShellOptions) => { + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); if ( command === `poetryPath env list --full-path` && - options.cwd && - externalDependencies.arePathsSame(options.cwd, project1) + cwd && + externalDependencies.arePathsSame(cwd, project1) ) { return Promise.resolve>({ stdout: '' }); } @@ -141,11 +142,8 @@ suite('Poetry binary is located correctly', async () => { test("When user hasn't specified a path, use poetry on PATH if available", async () => { getPythonSetting.returns('poetry'); // Setting returns the default value shellExecute.callsFake((command: string, options: ShellOptions) => { - if ( - command === `poetry env list --full-path` && - options.cwd && - externalDependencies.arePathsSame(options.cwd, project1) - ) { + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); + if (command === `poetry env list --full-path` && cwd && externalDependencies.arePathsSame(cwd, project1)) { return Promise.resolve>({ stdout: '' }); } return Promise.reject(new Error('Command failed')); @@ -168,10 +166,11 @@ suite('Poetry binary is located correctly', async () => { pathExistsSync.callThrough(); getPythonSetting.returns('poetry'); shellExecute.callsFake((command: string, options: ShellOptions) => { + const cwd = typeof options.cwd === 'string' ? options.cwd : options.cwd?.toString(); if ( command === `${defaultPoetry} env list --full-path` && - options.cwd && - externalDependencies.arePathsSame(options.cwd, project1) + cwd && + externalDependencies.arePathsSame(cwd, project1) ) { return Promise.resolve>({ stdout: '' }); } diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts index 1286ac44d58..786bd26a881 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvApi.unit.test.ts @@ -12,8 +12,8 @@ import * as commandApis from '../../../client/common/vscodeApis/commandApis'; import { IInterpreterQuickPick } from '../../../client/interpreter/configuration/types'; import { registerCreateEnvironmentFeatures } from '../../../client/pythonEnvironments/creation/createEnvApi'; import * as windowApis from '../../../client/common/vscodeApis/windowApis'; -import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/types'; import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvironment'; +import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -57,6 +57,11 @@ suite('Create Environment APIs', () => { [true, false].forEach((selectEnvironment) => { test(`Set environment selectEnvironment == ${selectEnvironment}`, async () => { + const workspace1 = { + uri: Uri.file('/path/to/env'), + name: 'workspace1', + index: 0, + }; const provider = typemoq.Mock.ofType(); provider.setup((p) => p.name).returns(() => 'test'); provider.setup((p) => p.id).returns(() => 'test-id'); @@ -66,7 +71,9 @@ suite('Create Environment APIs', () => { .returns(() => Promise.resolve({ path: '/path/to/env', - uri: Uri.file('/path/to/env'), + workspaceFolder: workspace1, + action: undefined, + error: undefined, }), ); provider.setup((p) => (p as any).then).returns(() => undefined); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts index 507a2aee88c..f16f8123336 100644 --- a/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/createEnvironment.unit.test.ts @@ -8,9 +8,9 @@ import * as typemoq from 'typemoq'; import { assert, use as chaiUse } from 'chai'; import * as windowApis from '../../../client/common/vscodeApis/windowApis'; import { handleCreateEnvironmentCommand } from '../../../client/pythonEnvironments/creation/createEnvironment'; -import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/types'; import { IDisposableRegistry } from '../../../client/common/types'; import { onCreateEnvironmentStarted } from '../../../client/pythonEnvironments/creation/createEnvApi'; +import { CreateEnvironmentProvider } from '../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -233,7 +233,12 @@ suite('Create Environments Tests', () => { showBackButton: true, }); - assert.deepStrictEqual(result, { path: undefined, uri: undefined, action: 'Back' }); + assert.deepStrictEqual(result, { + action: 'Back', + workspaceFolder: undefined, + path: undefined, + error: undefined, + }); assert.isTrue(showQuickPickStub.notCalled); assert.isTrue(showQuickPickWithBackStub.calledOnce); }); @@ -259,7 +264,12 @@ suite('Create Environments Tests', () => { showBackButton: true, }); - assert.deepStrictEqual(result, { path: undefined, uri: undefined, action: 'Cancel' }); + assert.deepStrictEqual(result, { + action: 'Cancel', + workspaceFolder: undefined, + path: undefined, + error: undefined, + }); assert.isTrue(showQuickPickStub.notCalled); assert.isTrue(showQuickPickWithBackStub.calledOnce); }); 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 db5eb351211..cb4df95c8c1 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 @@ -7,11 +7,7 @@ import { assert, use as chaiUse } from 'chai'; import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; import { CancellationToken, ProgressOptions, Uri } from 'vscode'; -import { - CreateEnvironmentProgress, - CreateEnvironmentProvider, - CreateEnvironmentResult, -} from '../../../../client/pythonEnvironments/creation/types'; +import { CreateEnvironmentProgress } from '../../../../client/pythonEnvironments/creation/types'; import { condaCreationProvider } from '../../../../client/pythonEnvironments/creation/provider/condaCreationProvider'; import * as wsSelect from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; import * as windowApis from '../../../../client/common/vscodeApis/windowApis'; @@ -23,6 +19,10 @@ import { createDeferred } from '../../../../client/common/utils/async'; import * as commonUtils from '../../../../client/pythonEnvironments/creation/common/commonUtils'; import { CONDA_ENV_CREATED_MARKER } from '../../../../client/pythonEnvironments/creation/provider/condaProgressAndTelemetry'; import { CreateEnv } from '../../../../client/common/utils/localize'; +import { + CreateEnvironmentProvider, + CreateEnvironmentResult, +} from '../../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -131,7 +131,12 @@ suite('Conda Creation provider tests', () => { _next!({ out: `${CONDA_ENV_CREATED_MARKER}new_environment`, source: 'stdout' }); _complete!(); - assert.deepStrictEqual(await promise, { path: 'new_environment', uri: workspace1.uri }); + assert.deepStrictEqual(await promise, { + path: 'new_environment', + workspaceFolder: workspace1, + action: undefined, + error: undefined, + }); assert.isTrue(showErrorMessageWithLogsStub.notCalled); }); 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 b56fb158d6a..1c22264f2ad 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 @@ -6,11 +6,7 @@ import * as typemoq from 'typemoq'; import { assert, use as chaiUse } from 'chai'; import * as sinon from 'sinon'; import { CancellationToken, ProgressOptions, Uri } from 'vscode'; -import { - CreateEnvironmentProgress, - CreateEnvironmentProvider, - CreateEnvironmentResult, -} from '../../../../client/pythonEnvironments/creation/types'; +import { CreateEnvironmentProgress } from '../../../../client/pythonEnvironments/creation/types'; import { VenvCreationProvider } from '../../../../client/pythonEnvironments/creation/provider/venvCreationProvider'; import { IInterpreterQuickPick } from '../../../../client/interpreter/configuration/types'; import * as wsSelect from '../../../../client/pythonEnvironments/creation/common/workspaceSelection'; @@ -23,6 +19,10 @@ import { Output } from '../../../../client/common/process/types'; import { VENV_CREATED_MARKER } from '../../../../client/pythonEnvironments/creation/provider/venvProgressAndTelemetry'; import { CreateEnv } from '../../../../client/common/utils/localize'; import * as venvUtils from '../../../../client/pythonEnvironments/creation/provider/venvUtils'; +import { + CreateEnvironmentProvider, + CreateEnvironmentResult, +} from '../../../../client/pythonEnvironments/creation/proposed.createEnvApis'; chaiUse(chaiAsPromised); @@ -155,7 +155,12 @@ suite('venv Creation provider tests', () => { _complete!(); const actual = await promise; - assert.deepStrictEqual(actual, { path: 'new_environment', uri: workspace1.uri }); + assert.deepStrictEqual(actual, { + path: 'new_environment', + workspaceFolder: workspace1, + action: undefined, + error: undefined, + }); interpreterQuickPick.verifyAll(); progressMock.verifyAll(); assert.isTrue(showErrorMessageWithLogsStub.notCalled); diff --git a/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvProgressAndTelemetry.unit.test.ts b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvProgressAndTelemetry.unit.test.ts new file mode 100644 index 00000000000..ecb7d1434ad --- /dev/null +++ b/extensions/positron-python/src/test/pythonEnvironments/creation/provider/venvProgressAndTelemetry.unit.test.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { + VENV_CREATED_MARKER, + VenvProgressAndTelemetry, +} from '../../../../client/pythonEnvironments/creation/provider/venvProgressAndTelemetry'; +import { CreateEnvironmentProgress } from '../../../../client/pythonEnvironments/creation/types'; +import * as telemetry from '../../../../client/telemetry'; +import { CreateEnv } from '../../../../client/common/utils/localize'; + +suite('Venv Progress and Telemetry', () => { + let sendTelemetryEventStub: sinon.SinonStub; + let progressReporterMock: typemoq.IMock; + + setup(() => { + sendTelemetryEventStub = sinon.stub(telemetry, 'sendTelemetryEvent'); + progressReporterMock = typemoq.Mock.ofType(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('Ensure telemetry event and progress are sent', async () => { + const progressReporter = progressReporterMock.object; + progressReporterMock + .setup((p) => p.report({ message: CreateEnv.Venv.created })) + .returns(() => undefined) + .verifiable(typemoq.Times.once()); + + const progressAndTelemetry = new VenvProgressAndTelemetry(progressReporter); + progressAndTelemetry.process(VENV_CREATED_MARKER); + assert.isTrue(sendTelemetryEventStub.calledOnce); + progressReporterMock.verifyAll(); + }); + + test('Do not trigger telemetry event the second time', async () => { + const progressReporter = progressReporterMock.object; + progressReporterMock + .setup((p) => p.report({ message: CreateEnv.Venv.created })) + .returns(() => undefined) + .verifiable(typemoq.Times.once()); + + const progressAndTelemetry = new VenvProgressAndTelemetry(progressReporter); + progressAndTelemetry.process(VENV_CREATED_MARKER); + progressAndTelemetry.process(VENV_CREATED_MARKER); + assert.isTrue(sendTelemetryEventStub.calledOnce); + progressReporterMock.verifyAll(); + }); +}); diff --git a/extensions/positron-python/src/test/serviceRegistry.ts b/extensions/positron-python/src/test/serviceRegistry.ts index e3c6763eace..1b8a9d78d58 100644 --- a/extensions/positron-python/src/test/serviceRegistry.ts +++ b/extensions/positron-python/src/test/serviceRegistry.ts @@ -4,8 +4,7 @@ import { Container } from 'inversify'; import { anything, instance, mock, when } from 'ts-mockito'; import * as TypeMoq from 'typemoq'; -import { Disposable, Memento, OutputChannel } from 'vscode'; -import { STANDARD_OUTPUT_CHANNEL } from '../client/common/constants'; +import { Disposable, Memento } from 'vscode'; import { IS_WINDOWS } from '../client/common/platform/constants'; import { FileSystem } from '../client/common/platform/fileSystem'; import { PathUtils } from '../client/common/platform/pathUtils'; @@ -28,10 +27,11 @@ import { ICurrentProcess, IDisposableRegistry, IMemento, - IOutputChannel, + ILogOutputChannel, IPathUtils, IsWindows, WORKSPACE_MEMENTO, + ITestOutputChannel, } from '../client/common/types'; import { registerTypes as variableRegisterTypes } from '../client/common/variables/serviceRegistry'; import { registerTypes as formattersRegisterTypes } from '../client/formatters/serviceRegistry'; @@ -48,7 +48,6 @@ import { ServiceContainer } from '../client/ioc/container'; import { ServiceManager } from '../client/ioc/serviceManager'; import { IServiceContainer, IServiceManager } from '../client/ioc/types'; import { registerTypes as lintersRegisterTypes } from '../client/linters/serviceRegistry'; -import { TEST_OUTPUT_CHANNEL } from '../client/testing/constants'; import { registerTypes as unittestsRegisterTypes } from '../client/testing/serviceRegistry'; import { LegacyFileSystem } from './legacyFileSystem'; import { MockOutputChannel } from './mockClasses'; @@ -83,14 +82,10 @@ export class IocContainer { const stdOutputChannel = new MockOutputChannel('Python'); this.disposables.push(stdOutputChannel); - this.serviceManager.addSingletonInstance( - IOutputChannel, - stdOutputChannel, - STANDARD_OUTPUT_CHANNEL, - ); + this.serviceManager.addSingletonInstance(ILogOutputChannel, stdOutputChannel); const testOutputChannel = new MockOutputChannel('Python Test - UnitTests'); this.disposables.push(testOutputChannel); - this.serviceManager.addSingletonInstance(IOutputChannel, testOutputChannel, TEST_OUTPUT_CHANNEL); + this.serviceManager.addSingletonInstance(ITestOutputChannel, testOutputChannel); this.serviceManager.addSingleton( IInterpreterAutoSelectionService, diff --git a/extensions/positron-python/src/test/terminals/codeExecution/helper.test.ts b/extensions/positron-python/src/test/terminals/codeExecution/helper.test.ts index 07a91f8e10d..57bf51883eb 100644 --- a/extensions/positron-python/src/test/terminals/codeExecution/helper.test.ts +++ b/extensions/positron-python/src/test/terminals/codeExecution/helper.test.ts @@ -8,8 +8,8 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import { SemVer } from 'semver'; import * as TypeMoq from 'typemoq'; -import { Position, Range, Selection, TextDocument, TextEditor, TextLine, Uri } from 'vscode'; -import { IApplicationShell, IDocumentManager } from '../../../client/common/application/types'; +import { EventEmitter, Position, Range, Selection, TextDocument, TextEditor, TextLine, Uri } from 'vscode'; +import { IApplicationShell, ICommandManager, IDocumentManager } from '../../../client/common/application/types'; import { EXTENSION_ROOT_DIR, PYTHON_LANGUAGE } from '../../../client/common/constants'; import '../../../client/common/extensions'; import { ProcessService } from '../../../client/common/process/proc'; @@ -37,6 +37,7 @@ suite('Terminal - Code Execution Helper', () => { let editor: TypeMoq.IMock; let processService: TypeMoq.IMock; let interpreterService: TypeMoq.IMock; + let commandManager: TypeMoq.IMock; const workingPython: PythonEnvironment = { path: PYTHON_PATH, version: new SemVer('3.6.6-final'), @@ -49,6 +50,7 @@ suite('Terminal - Code Execution Helper', () => { setup(() => { const serviceContainer = TypeMoq.Mock.ofType(); + commandManager = TypeMoq.Mock.ofType(); documentManager = TypeMoq.Mock.ofType(); applicationShell = TypeMoq.Mock.ofType(); const envVariablesProvider = TypeMoq.Mock.ofType(); @@ -79,6 +81,7 @@ suite('Terminal - Code Execution Helper', () => { serviceContainer .setup((c) => c.get(TypeMoq.It.isValue(IApplicationShell), TypeMoq.It.isAny())) .returns(() => applicationShell.object); + serviceContainer.setup((c) => c.get(TypeMoq.It.isValue(ICommandManager))).returns(() => commandManager.object); serviceContainer .setup((c) => c.get(TypeMoq.It.isValue(IEnvironmentVariablesProvider), TypeMoq.It.isAny())) .returns(() => envVariablesProvider.object); @@ -364,15 +367,24 @@ suite('Terminal - Code Execution Helper', () => { .setup((d) => d.textDocuments) .returns(() => [document.object]) .verifiable(TypeMoq.Times.once()); - document.setup((doc) => doc.isUntitled).returns(() => false); + const saveEmitter = new EventEmitter(); + documentManager.setup((d) => d.onDidSaveTextDocument).returns(() => saveEmitter.event); + document.setup((doc) => doc.isUntitled).returns(() => true); document.setup((doc) => doc.isDirty).returns(() => true); document.setup((doc) => doc.languageId).returns(() => PYTHON_LANGUAGE); - const expectedUri = Uri.file('one.py'); - document.setup((doc) => doc.uri).returns(() => expectedUri); - - await helper.saveFileIfDirty(expectedUri); - documentManager.verifyAll(); - document.verify((doc) => doc.save(), TypeMoq.Times.once()); + const untitledUri = Uri.file('Untitled-1'); + document.setup((doc) => doc.uri).returns(() => untitledUri); + const savedDocument = TypeMoq.Mock.ofType(); + const expectedSavedUri = Uri.file('one.py'); + savedDocument.setup((doc) => doc.uri).returns(() => expectedSavedUri); + commandManager + .setup((c) => c.executeCommand('workbench.action.files.save', untitledUri)) + .callback(() => saveEmitter.fire(savedDocument.object)) + .returns(() => Promise.resolve()); + + const savedUri = await helper.saveFileIfDirty(untitledUri); + + expect(savedUri?.fsPath).to.be.equal(expectedSavedUri.fsPath); }); test('File will be not saved if file is not dirty', async () => { diff --git a/extensions/positron-python/src/test/testLogger.ts b/extensions/positron-python/src/test/testLogger.ts index b41f17ecafc..26484ee119c 100644 --- a/extensions/positron-python/src/test/testLogger.ts +++ b/extensions/positron-python/src/test/testLogger.ts @@ -5,6 +5,7 @@ import { initializeFileLogging, logTo } from '../client/logging'; import { LogLevel } from '../client/logging/types'; + // IMPORTANT: This file should only be importing from the '../client/logging' directory, as we // delete everything in '../client' except for '../client/logging' before running smoke tests. @@ -31,7 +32,7 @@ function monkeypatchConsole() { const streams = ['log', 'error', 'warn', 'info', 'debug', 'trace']; const levels: { [key: string]: LogLevel } = { error: LogLevel.Error, - warn: LogLevel.Warn, + warn: LogLevel.Warning, debug: LogLevel.Debug, trace: LogLevel.Debug, info: LogLevel.Info, diff --git a/extensions/positron-python/src/test/testing/common/managers/testConfigurationManager.unit.test.ts b/extensions/positron-python/src/test/testing/common/managers/testConfigurationManager.unit.test.ts index 02a7193172a..c8b6085e599 100644 --- a/extensions/positron-python/src/test/testing/common/managers/testConfigurationManager.unit.test.ts +++ b/extensions/positron-python/src/test/testing/common/managers/testConfigurationManager.unit.test.ts @@ -5,13 +5,12 @@ import * as TypeMoq from 'typemoq'; import { OutputChannel, Uri } from 'vscode'; -import { IInstaller, IOutputChannel, Product } from '../../../../client/common/types'; +import { IInstaller, ITestOutputChannel, Product } from '../../../../client/common/types'; import { getNamesAndValues } from '../../../../client/common/utils/enum'; import { IServiceContainer } from '../../../../client/ioc/types'; import { UNIT_TEST_PRODUCTS } from '../../../../client/testing/common/constants'; import { TestConfigurationManager } from '../../../../client/testing/common/testConfigurationManager'; import { ITestConfigSettingsService, UnitTestProduct } from '../../../../client/testing/common/types'; -import { TEST_OUTPUT_CHANNEL } from '../../../../client/testing/constants'; class MockTestConfigurationManager extends TestConfigurationManager { // The workspace arg is ignored. @@ -42,7 +41,7 @@ suite('Unit Test Configuration Manager (unit)', () => { const installer = TypeMoq.Mock.ofType().object; const serviceContainer = TypeMoq.Mock.ofType(); serviceContainer - .setup((s) => s.get(TypeMoq.It.isValue(IOutputChannel), TypeMoq.It.isValue(TEST_OUTPUT_CHANNEL))) + .setup((s) => s.get(TypeMoq.It.isValue(ITestOutputChannel))) .returns(() => outputChannel); serviceContainer .setup((s) => s.get(TypeMoq.It.isValue(ITestConfigSettingsService))) diff --git a/extensions/positron-python/src/test/testing/configuration.unit.test.ts b/extensions/positron-python/src/test/testing/configuration.unit.test.ts index fec936a2a21..abb57aac230 100644 --- a/extensions/positron-python/src/test/testing/configuration.unit.test.ts +++ b/extensions/positron-python/src/test/testing/configuration.unit.test.ts @@ -7,7 +7,13 @@ import { expect } from 'chai'; import * as typeMoq from 'typemoq'; import { OutputChannel, Uri, WorkspaceConfiguration } from 'vscode'; import { IApplicationShell, ICommandManager, IWorkspaceService } from '../../client/common/application/types'; -import { IConfigurationService, IInstaller, IOutputChannel, IPythonSettings, Product } from '../../client/common/types'; +import { + IConfigurationService, + IInstaller, + ITestOutputChannel, + IPythonSettings, + Product, +} from '../../client/common/types'; import { getNamesAndValues } from '../../client/common/utils/enum'; import { IServiceContainer } from '../../client/ioc/types'; import { UNIT_TEST_PRODUCTS } from '../../client/testing/common/constants'; @@ -18,7 +24,6 @@ import { ITestConfigurationManagerFactory, ITestsHelper, } from '../../client/testing/common/types'; -import { TEST_OUTPUT_CHANNEL } from '../../client/testing/constants'; import { ITestingSettings } from '../../client/testing/configuration/types'; import { NONE_SELECTED, UnitTestConfigurationService } from '../../client/testing/configuration'; @@ -56,7 +61,7 @@ suite('Unit Tests - ConfigurationService', () => { configurationService.setup((c) => c.getSettings(workspaceUri)).returns(() => pythonSettings.object); serviceContainer - .setup((c) => c.get(typeMoq.It.isValue(IOutputChannel), typeMoq.It.isValue(TEST_OUTPUT_CHANNEL))) + .setup((c) => c.get(typeMoq.It.isValue(ITestOutputChannel))) .returns(() => outputChannel.object); serviceContainer.setup((c) => c.get(typeMoq.It.isValue(IInstaller))).returns(() => installer.object); serviceContainer diff --git a/extensions/positron-python/src/test/testing/configurationFactory.unit.test.ts b/extensions/positron-python/src/test/testing/configurationFactory.unit.test.ts index 1418147d615..74f7dd0da19 100644 --- a/extensions/positron-python/src/test/testing/configurationFactory.unit.test.ts +++ b/extensions/positron-python/src/test/testing/configurationFactory.unit.test.ts @@ -7,11 +7,10 @@ import { expect, use } from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import * as typeMoq from 'typemoq'; import { OutputChannel, Uri } from 'vscode'; -import { IInstaller, IOutputChannel, Product } from '../../client/common/types'; +import { IInstaller, ITestOutputChannel, Product } from '../../client/common/types'; import { IServiceContainer } from '../../client/ioc/types'; import { ITestConfigSettingsService, ITestConfigurationManagerFactory } from '../../client/testing/common/types'; import { TestConfigurationManagerFactory } from '../../client/testing/configurationFactory'; -import { TEST_OUTPUT_CHANNEL } from '../../client/testing/constants'; import * as pytest from '../../client/testing/configuration/pytest/testConfigurationManager'; import * as unittest from '../../client/testing/configuration/unittest/testConfigurationManager'; @@ -26,7 +25,7 @@ suite('Unit Tests - ConfigurationManagerFactory', () => { const testConfigService = typeMoq.Mock.ofType(); serviceContainer - .setup((c) => c.get(typeMoq.It.isValue(IOutputChannel), typeMoq.It.isValue(TEST_OUTPUT_CHANNEL))) + .setup((c) => c.get(typeMoq.It.isValue(ITestOutputChannel))) .returns(() => outputChannel.object); serviceContainer.setup((c) => c.get(typeMoq.It.isValue(IInstaller))).returns(() => installer.object); serviceContainer 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 new file mode 100644 index 00000000000..12c79a23c7f --- /dev/null +++ b/extensions/positron-python/src/test/testing/testController/pytest/pytestDiscoveryAdapter.unit.test.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +import * as assert from 'assert'; +import { Uri } from 'vscode'; +import * as typeMoq from 'typemoq'; +import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; +import { PytestTestDiscoveryAdapter } from '../../../../client/testing/testController/pytest/pytestDiscoveryAdapter'; +import { DataReceivedEvent, ITestServer } from '../../../../client/testing/testController/common/types'; +import { IPythonExecutionFactory, IPythonExecutionService } from '../../../../client/common/process/types'; +import { createDeferred, Deferred } from '../../../../client/common/utils/async'; + +suite('pytest test discovery adapter', () => { + let testServer: typeMoq.IMock; + let configService: IConfigurationService; + let execFactory = typeMoq.Mock.ofType(); + let adapter: PytestTestDiscoveryAdapter; + let execService: typeMoq.IMock; + let deferred: Deferred; + let outputChannel: typeMoq.IMock; + + setup(() => { + testServer = typeMoq.Mock.ofType(); + testServer.setup((t) => t.getPort()).returns(() => 12345); + testServer + .setup((t) => t.onDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => ({ + dispose: () => { + /* no-body */ + }, + })); + configService = ({ + getSettings: () => ({ + testing: { pytestArgs: ['.'] }, + }), + } as unknown) as IConfigurationService; + execFactory = typeMoq.Mock.ofType(); + execService = typeMoq.Mock.ofType(); + execFactory + .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) + .returns(() => Promise.resolve(execService.object)); + deferred = createDeferred(); + execService + .setup((x) => x.exec(typeMoq.It.isAny(), typeMoq.It.isAny())) + .returns(() => { + deferred.resolve(); + return Promise.resolve({ stdout: '{}' }); + }); + execFactory.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); + outputChannel = typeMoq.Mock.ofType(); + }); + test('onDataReceivedHandler should parse only if known UUID', async () => { + const uri = Uri.file('/my/test/path/'); + const uuid = 'uuid123'; + const data = { status: 'success' }; + testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); + const eventData: DataReceivedEvent = { + uuid, + data: JSON.stringify(data), + }; + + adapter = new PytestTestDiscoveryAdapter(testServer.object, configService, outputChannel.object); + const promise = adapter.discoverTests(uri, execFactory.object); + // const promise = adapter.discoverTests(uri); + await deferred.promise; + adapter.onDataReceivedHandler(eventData); + const result = await promise; + assert.deepStrictEqual(result, data); + }); + test('onDataReceivedHandler should not parse if it is unknown UUID', async () => { + const uri = Uri.file('/my/test/path/'); + const uuid = 'uuid456'; + let data = { status: 'error' }; + testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); + const wrongUriEventData: DataReceivedEvent = { + uuid: 'incorrect-uuid456', + data: JSON.stringify(data), + }; + adapter = new PytestTestDiscoveryAdapter(testServer.object, configService, outputChannel.object); + const promise = adapter.discoverTests(uri, execFactory.object); + // const promise = adapter.discoverTests(uri); + adapter.onDataReceivedHandler(wrongUriEventData); + + data = { status: 'success' }; + const correctUriEventData: DataReceivedEvent = { + uuid, + data: JSON.stringify(data), + }; + adapter.onDataReceivedHandler(correctUriEventData); + const result = await promise; + assert.deepStrictEqual(result, data); + }); +}); 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 new file mode 100644 index 00000000000..ac6c6bd274a --- /dev/null +++ b/extensions/positron-python/src/test/testing/testController/pytest/pytestExecutionAdapter.unit.test.ts @@ -0,0 +1,90 @@ +// /* eslint-disable @typescript-eslint/no-explicit-any */ +// // Copyright (c) Microsoft Corporation. All rights reserved. +// // Licensed under the MIT License. +// import * as assert from 'assert'; +// import { Uri } from 'vscode'; +// import * as typeMoq from 'typemoq'; +// import { IConfigurationService } from '../../../../client/common/types'; +// import { DataReceivedEvent, ITestServer } from '../../../../client/testing/testController/common/types'; +// import { IPythonExecutionFactory, IPythonExecutionService } from '../../../../client/common/process/types'; +// import { createDeferred, Deferred } from '../../../../client/common/utils/async'; +// import { PytestTestExecutionAdapter } from '../../../../client/testing/testController/pytest/pytestExecutionAdapter'; + +// suite('pytest test execution adapter', () => { +// let testServer: typeMoq.IMock; +// let configService: IConfigurationService; +// let execFactory = typeMoq.Mock.ofType(); +// let adapter: PytestTestExecutionAdapter; +// let execService: typeMoq.IMock; +// let deferred: Deferred; +// setup(() => { +// testServer = typeMoq.Mock.ofType(); +// testServer.setup((t) => t.getPort()).returns(() => 12345); +// testServer +// .setup((t) => t.onDataReceived(typeMoq.It.isAny(), typeMoq.It.isAny())) +// .returns(() => ({ +// dispose: () => { +// /* no-body */ +// }, +// })); +// configService = ({ +// getSettings: () => ({ +// testing: { pytestArgs: ['.'] }, +// }), +// isTestExecution: () => false, +// } as unknown) as IConfigurationService; +// execFactory = typeMoq.Mock.ofType(); +// execService = typeMoq.Mock.ofType(); +// execFactory +// .setup((x) => x.createActivatedEnvironment(typeMoq.It.isAny())) +// .returns(() => Promise.resolve(execService.object)); +// deferred = createDeferred(); +// execService +// .setup((x) => x.exec(typeMoq.It.isAny(), typeMoq.It.isAny())) +// .returns(() => { +// deferred.resolve(); +// return Promise.resolve({ stdout: '{}' }); +// }); +// execFactory.setup((p) => ((p as unknown) as any).then).returns(() => undefined); +// execService.setup((p) => ((p as unknown) as any).then).returns(() => undefined); +// }); +// test('onDataReceivedHandler should parse only if known UUID', async () => { +// const uri = Uri.file('/my/test/path/'); +// const uuid = 'uuid123'; +// const data = { status: 'success' }; +// testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); +// const eventData: DataReceivedEvent = { +// uuid, +// data: JSON.stringify(data), +// }; + +// adapter = new PytestTestExecutionAdapter(testServer.object, configService); +// const promise = adapter.runTests(uri, [], false); +// await deferred.promise; +// adapter.onDataReceivedHandler(eventData); +// const result = await promise; +// assert.deepStrictEqual(result, data); +// }); +// test('onDataReceivedHandler should not parse if it is unknown UUID', async () => { +// const uri = Uri.file('/my/test/path/'); +// const uuid = 'uuid456'; +// let data = { status: 'error' }; +// testServer.setup((t) => t.createUUID(typeMoq.It.isAny())).returns(() => uuid); +// const wrongUriEventData: DataReceivedEvent = { +// uuid: 'incorrect-uuid456', +// data: JSON.stringify(data), +// }; +// adapter = new PytestTestExecutionAdapter(testServer.object, configService); +// const promise = adapter.runTests(uri, [], false); +// adapter.onDataReceivedHandler(wrongUriEventData); + +// data = { status: 'success' }; +// const correctUriEventData: DataReceivedEvent = { +// uuid, +// data: JSON.stringify(data), +// }; +// adapter.onDataReceivedHandler(correctUriEventData); +// const result = await promise; +// assert.deepStrictEqual(result, data); +// }); +// }); 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 59aeeda333b..d7b3a242ee9 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 @@ -2,15 +2,14 @@ // Licensed under the MIT License. import * as assert from 'assert'; -import * as http from 'http'; +import * as net from 'net'; import * as sinon from 'sinon'; import * as crypto from 'crypto'; import { OutputChannel, Uri } from 'vscode'; import { IPythonExecutionFactory, IPythonExecutionService } from '../../../client/common/process/types'; -import { createDeferred } from '../../../client/common/utils/async'; import { PythonTestServer } from '../../../client/testing/testController/common/server'; -import * as logging from '../../../client/logging'; import { ITestDebugLauncher } from '../../../client/testing/common/types'; +import { createDeferred } from '../../../client/common/utils/async'; suite('Python Test Server', () => { const fakeUuid = 'fake-uuid'; @@ -21,13 +20,11 @@ suite('Python Test Server', () => { let sandbox: sinon.SinonSandbox; let execArgs: string[]; let v4Stub: sinon.SinonStub; - let traceLogStub: sinon.SinonStub; let debugLauncher: ITestDebugLauncher; setup(() => { sandbox = sinon.createSandbox(); v4Stub = sandbox.stub(crypto, 'randomUUID'); - traceLogStub = sandbox.stub(logging, 'traceLog'); v4Stub.returns(fakeUuid); stubExecutionService = ({ @@ -48,11 +45,12 @@ suite('Python Test Server', () => { server.dispose(); }); - test('sendCommand should add the port and uuid to the command being sent', async () => { + test('sendCommand should add the port to the command being sent', async () => { const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', + uuid: fakeUuid, }; server = new PythonTestServer(stubExecutionFactory, debugLauncher); @@ -75,6 +73,7 @@ suite('Python Test Server', () => { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', + uuid: fakeUuid, outChannel, }; @@ -101,6 +100,7 @@ suite('Python Test Server', () => { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', + uuid: fakeUuid, }; server = new PythonTestServer(stubExecutionFactory, debugLauncher); @@ -116,180 +116,43 @@ suite('Python Test Server', () => { assert.deepStrictEqual(eventData!.errors, ['Failed to execute']); }); - test('If the server receives data, it should fire an event if it is a known uuid', async () => { - const deferred = createDeferred(); - const options = { - command: { script: 'myscript', args: ['-foo', 'foo'] }, - workspaceFolder: Uri.file('/foo/bar'), - cwd: '/foo/bar', - }; - - let response; - - server = new PythonTestServer(stubExecutionFactory, debugLauncher); - await server.serverReady(); - - server.onDataReceived(({ data }) => { - response = data; - deferred.resolve(); - }); - - await server.sendCommand(options); - - // Send data back. - const port = server.getPort(); - const requestOptions = { - hostname: 'localhost', - method: 'POST', - port, - headers: { 'Request-uuid': fakeUuid }, - }; - - const request = http.request(requestOptions, (res) => { - res.setEncoding('utf8'); - }); - - const postData = JSON.stringify({ status: 'success', uuid: fakeUuid }); - request.write(postData); - request.end(); - - await deferred.promise; - - assert.deepStrictEqual(response, postData); - }); 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', - }; - - let response; - server = new PythonTestServer(stubExecutionFactory, debugLauncher); - await server.serverReady(); - - server.onDataReceived(({ data }) => { - response = data; - deferred.resolve(); - }); - - await server.sendCommand(options); - - // Send data back. - const port = server.getPort(); - const requestOptions = { - hostname: 'localhost', - method: 'POST', - port, - headers: { 'Request-uuid': fakeUuid }, - }; - - const request = http.request(requestOptions, (res) => { - res.setEncoding('utf8'); - }); - const postData = '[test'; - request.write(postData); - request.end(); - - await deferred.promise; - - sinon.assert.calledOnce(traceLogStub); - assert.deepStrictEqual(response, ''); - }); - - test('If the server receives data, it should not fire an event if it is an unknown uuid', async () => { - const deferred = createDeferred(); const options = { command: { script: 'myscript', args: ['-foo', 'foo'] }, workspaceFolder: Uri.file('/foo/bar'), cwd: '/foo/bar', + uuid: fakeUuid, }; - let response; + stubExecutionService = ({ + exec: async () => { + client.connect(server.getPort()); + return Promise.resolve({ stdout: '', stderr: '' }); + }, + } as unknown) as IPythonExecutionService; server = new PythonTestServer(stubExecutionFactory, debugLauncher); await server.serverReady(); - server.onDataReceived(({ data }) => { - response = data; + eventData = data; deferred.resolve(); }); - await server.sendCommand(options); - - // Send data back. - const port = server.getPort(); - const requestOptions = { - hostname: 'localhost', - method: 'POST', - port, - headers: { 'Request-uuid': fakeUuid }, - }; - // request.hasHeader() - const request = http.request(requestOptions, (res) => { - res.setEncoding('utf8'); + client.on('connect', () => { + console.log('Socket connected, local port:', client.localPort); + client.write('malformed data'); + client.end(); }); - const postData = JSON.stringify({ status: 'success', uuid: fakeUuid, payload: 'foo' }); - request.write(postData); - request.end(); - - await deferred.promise; - - assert.deepStrictEqual(response, postData); - }); - - test('If the server receives data, it should not fire an event if there is no uuid', async () => { - const deferred = createDeferred(); - const options = { - command: { script: 'myscript', args: ['-foo', 'foo'] }, - workspaceFolder: Uri.file('/foo/bar'), - cwd: '/foo/bar', - }; - - let response; - - server = new PythonTestServer(stubExecutionFactory, debugLauncher); - await server.serverReady(); - - server.onDataReceived(({ data }) => { - response = data; - deferred.resolve(); + client.on('error', (error) => { + console.log('Socket connection error:', error); }); await server.sendCommand(options); - - // Send data back. - const port = server.getPort(); - const requestOptions = { - hostname: 'localhost', - method: 'POST', - port, - headers: { 'Request-uuid': 'some-other-uuid' }, - }; - const requestOptions2 = { - hostname: 'localhost', - method: 'POST', - port, - headers: { 'Request-uuid': fakeUuid }, - }; - const requestOne = http.request(requestOptions, (res) => { - res.setEncoding('utf8'); - }); - const postDataOne = JSON.stringify({ status: 'success', uuid: 'some-other-uuid', payload: 'foo' }); - requestOne.write(postDataOne); - requestOne.end(); - - const requestTwo = http.request(requestOptions2, (res) => { - res.setEncoding('utf8'); - }); - const postDataTwo = JSON.stringify({ status: 'success', uuid: fakeUuid, payload: 'foo' }); - requestTwo.write(postDataTwo); - requestTwo.end(); - await deferred.promise; - - assert.deepStrictEqual(response, postDataTwo); + assert.deepStrictEqual(eventData, ''); }); }); diff --git a/extensions/positron-python/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts b/extensions/positron-python/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts index cee4353db09..3d3521291f7 100644 --- a/extensions/positron-python/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts +++ b/extensions/positron-python/src/test/testing/testController/unittest/testDiscoveryAdapter.unit.test.ts @@ -3,14 +3,16 @@ import * as assert from 'assert'; import * as path from 'path'; +import * as typemoq from 'typemoq'; import { Uri } from 'vscode'; -import { IConfigurationService } from '../../../../client/common/types'; +import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; import { EXTENSION_ROOT_DIR } from '../../../../client/constants'; import { ITestServer, TestCommandOptions } from '../../../../client/testing/testController/common/types'; import { UnittestTestDiscoveryAdapter } from '../../../../client/testing/testController/unittest/testDiscoveryAdapter'; suite('Unittest test discovery adapter', () => { let stubConfigSettings: IConfigurationService; + let outputChannel: typemoq.IMock; setup(() => { stubConfigSettings = ({ @@ -18,6 +20,7 @@ suite('Unittest test discovery adapter', () => { testing: { unittestArgs: ['-v', '-s', '.', '-p', 'test*'] }, }), } as unknown) as IConfigurationService; + outputChannel = typemoq.Mock.ofType(); }); test('discoverTests should send the discovery command to the test server', async () => { @@ -25,24 +28,27 @@ suite('Unittest test discovery adapter', () => { const stubTestServer = ({ sendCommand(opt: TestCommandOptions): Promise { + delete opt.outChannel; options = opt; return Promise.resolve(); }, onDataReceived: () => { // no body }, + createUUID: () => '123456789', } as unknown) as ITestServer; const uri = Uri.file('/foo/bar'); const script = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'unittestadapter', 'discovery.py'); - const adapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); + const adapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings, outputChannel.object); adapter.discoverTests(uri); assert.deepStrictEqual(options, { workspaceFolder: uri, cwd: uri.fsPath, command: { script, args: ['--udiscovery', '-v', '-s', '.', '-p', 'test*'] }, + uuid: '123456789', }); }); @@ -54,15 +60,16 @@ suite('Unittest test discovery adapter', () => { onDataReceived: () => { // no body }, + createUUID: () => '123456789', } as unknown) as ITestServer; const uri = Uri.file('/foo/bar'); const data = { status: 'success' }; - - const adapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); + const uuid = '123456789'; + const adapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings, outputChannel.object); const promise = adapter.discoverTests(uri); - adapter.onDataReceivedHandler({ cwd: uri.fsPath, data: JSON.stringify(data) }); + adapter.onDataReceivedHandler({ uuid, data: JSON.stringify(data) }); const result = await promise; @@ -70,6 +77,8 @@ suite('Unittest test discovery adapter', () => { }); test("onDataReceivedHandler should ignore the data if the cwd from the payload does not match the test adapter's cwd", async () => { + const correctUuid = '123456789'; + const incorrectUuid = '987654321'; const stubTestServer = ({ sendCommand(): Promise { return Promise.resolve(); @@ -77,18 +86,19 @@ suite('Unittest test discovery adapter', () => { onDataReceived: () => { // no body }, + createUUID: () => correctUuid, } as unknown) as ITestServer; const uri = Uri.file('/foo/bar'); - const adapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); + const adapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings, outputChannel.object); const promise = adapter.discoverTests(uri); const data = { status: 'success' }; - adapter.onDataReceivedHandler({ cwd: 'some/other/path', data: JSON.stringify(data) }); + adapter.onDataReceivedHandler({ uuid: incorrectUuid, data: JSON.stringify(data) }); const nextData = { status: 'error' }; - adapter.onDataReceivedHandler({ cwd: uri.fsPath, data: JSON.stringify(nextData) }); + adapter.onDataReceivedHandler({ uuid: correctUuid, data: JSON.stringify(nextData) }); const result = await promise; diff --git a/extensions/positron-python/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts b/extensions/positron-python/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts new file mode 100644 index 00000000000..d88f033d39a --- /dev/null +++ b/extensions/positron-python/src/test/testing/testController/unittest/testExecutionAdapter.unit.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as typemoq from 'typemoq'; +import { Uri } from 'vscode'; +import { IConfigurationService, ITestOutputChannel } from '../../../../client/common/types'; +import { EXTENSION_ROOT_DIR } from '../../../../client/constants'; +import { ITestServer, TestCommandOptions } from '../../../../client/testing/testController/common/types'; +import { UnittestTestExecutionAdapter } from '../../../../client/testing/testController/unittest/testExecutionAdapter'; + +suite('Unittest test execution adapter', () => { + let stubConfigSettings: IConfigurationService; + let outputChannel: typemoq.IMock; + + setup(() => { + stubConfigSettings = ({ + getSettings: () => ({ + testing: { unittestArgs: ['-v', '-s', '.', '-p', 'test*'] }, + }), + } as unknown) as IConfigurationService; + outputChannel = typemoq.Mock.ofType(); + }); + + test('runTests should send the run command to the test server', async () => { + let options: TestCommandOptions | undefined; + + const stubTestServer = ({ + sendCommand(opt: TestCommandOptions): Promise { + delete opt.outChannel; + options = opt; + return Promise.resolve(); + }, + onDataReceived: () => { + // no body + }, + createUUID: () => '123456789', + } as unknown) as ITestServer; + + const uri = Uri.file('/foo/bar'); + const script = path.join(EXTENSION_ROOT_DIR, 'pythonFiles', 'unittestadapter', 'execution.py'); + + const adapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings, outputChannel.object); + adapter.runTests(uri, [], false); + + const expectedOptions: TestCommandOptions = { + workspaceFolder: uri, + command: { script, args: ['--udiscovery', '-v', '-s', '.', '-p', 'test*'] }, + cwd: uri.fsPath, + uuid: '123456789', + debugBool: false, + testIds: [], + }; + + assert.deepStrictEqual(options, expectedOptions); + }); + test("onDataReceivedHandler should parse the data if the cwd from the payload matches the test adapter's cwd", async () => { + const stubTestServer = ({ + sendCommand(): Promise { + return Promise.resolve(); + }, + onDataReceived: () => { + // no body + }, + createUUID: () => '123456789', + } as unknown) as ITestServer; + + const uri = Uri.file('/foo/bar'); + const data = { status: 'success' }; + const uuid = '123456789'; + + const adapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings, outputChannel.object); + + // triggers runTests flow which will run onDataReceivedHandler and the + // promise resolves into the parsed data. + const promise = adapter.runTests(uri, [], false); + + adapter.onDataReceivedHandler({ uuid, data: JSON.stringify(data) }); + + const result = await promise; + + assert.deepStrictEqual(result, data); + }); + test("onDataReceivedHandler should ignore the data if the cwd from the payload does not match the test adapter's cwd", async () => { + const correctUuid = '123456789'; + const incorrectUuid = '987654321'; + const stubTestServer = ({ + sendCommand(): Promise { + return Promise.resolve(); + }, + onDataReceived: () => { + // no body + }, + createUUID: () => correctUuid, + } as unknown) as ITestServer; + + const uri = Uri.file('/foo/bar'); + + const adapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings, outputChannel.object); + + // triggers runTests flow which will run onDataReceivedHandler and the + // promise resolves into the parsed data. + const promise = adapter.runTests(uri, [], false); + + const data = { status: 'success' }; + // will not resolve due to incorrect UUID + adapter.onDataReceivedHandler({ uuid: incorrectUuid, data: JSON.stringify(data) }); + + const nextData = { status: 'error' }; + // will resolve and nextData will be returned as result + adapter.onDataReceivedHandler({ uuid: correctUuid, data: JSON.stringify(nextData) }); + + const result = await promise; + + assert.deepStrictEqual(result, nextData); + }); +}); diff --git a/extensions/positron-python/src/test/testing/testController/utils.unit.test.ts b/extensions/positron-python/src/test/testing/testController/utils.unit.test.ts new file mode 100644 index 00000000000..d971c7d37c9 --- /dev/null +++ b/extensions/positron-python/src/test/testing/testController/utils.unit.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { + JSONRPC_CONTENT_LENGTH_HEADER, + JSONRPC_CONTENT_TYPE_HEADER, + JSONRPC_UUID_HEADER, + jsonRPCContent, + jsonRPCHeaders, +} from '../../../client/testing/testController/common/utils'; + +suite('Test Controller Utils: JSON RPC', () => { + test('Empty raw data string', async () => { + const rawDataString = ''; + + const output = jsonRPCHeaders(rawDataString); + assert.deepStrictEqual(output.headers.size, 0); + assert.deepStrictEqual(output.remainingRawData, ''); + }); + + test('Valid data empty JSON', async () => { + const rawDataString = `${JSONRPC_CONTENT_LENGTH_HEADER}: 2\n${JSONRPC_CONTENT_TYPE_HEADER}: application/json\n${JSONRPC_UUID_HEADER}: 1234\n\n{}`; + + const rpcHeaders = jsonRPCHeaders(rawDataString); + assert.deepStrictEqual(rpcHeaders.headers.size, 3); + assert.deepStrictEqual(rpcHeaders.remainingRawData, '{}'); + const rpcContent = jsonRPCContent(rpcHeaders.headers, rpcHeaders.remainingRawData); + assert.deepStrictEqual(rpcContent.extractedJSON, '{}'); + }); + + test('Valid data NO JSON', async () => { + const rawDataString = `${JSONRPC_CONTENT_LENGTH_HEADER}: 0\n${JSONRPC_CONTENT_TYPE_HEADER}: application/json\n${JSONRPC_UUID_HEADER}: 1234\n\n`; + + const rpcHeaders = jsonRPCHeaders(rawDataString); + assert.deepStrictEqual(rpcHeaders.headers.size, 3); + assert.deepStrictEqual(rpcHeaders.remainingRawData, ''); + const rpcContent = jsonRPCContent(rpcHeaders.headers, rpcHeaders.remainingRawData); + assert.deepStrictEqual(rpcContent.extractedJSON, ''); + }); + + test('Valid data with full JSON', async () => { + // this is just some random JSON + const json = + '{"jsonrpc": "2.0", "method": "initialize", "params": {"processId": 1234, "rootPath": "/home/user/project", "rootUri": "file:///home/user/project", "capabilities": {}}, "id": 0}'; + const rawDataString = `${JSONRPC_CONTENT_LENGTH_HEADER}: ${json.length}\n${JSONRPC_CONTENT_TYPE_HEADER}: application/json\n${JSONRPC_UUID_HEADER}: 1234\n\n${json}`; + + const rpcHeaders = jsonRPCHeaders(rawDataString); + assert.deepStrictEqual(rpcHeaders.headers.size, 3); + assert.deepStrictEqual(rpcHeaders.remainingRawData, json); + const rpcContent = jsonRPCContent(rpcHeaders.headers, rpcHeaders.remainingRawData); + assert.deepStrictEqual(rpcContent.extractedJSON, json); + }); + + test('Valid data with multiple JSON', async () => { + const json = + '{"jsonrpc": "2.0", "method": "initialize", "params": {"processId": 1234, "rootPath": "/home/user/project", "rootUri": "file:///home/user/project", "capabilities": {}}, "id": 0}'; + const rawDataString = `${JSONRPC_CONTENT_LENGTH_HEADER}: ${json.length}\n${JSONRPC_CONTENT_TYPE_HEADER}: application/json\n${JSONRPC_UUID_HEADER}: 1234\n\n${json}`; + const rawDataString2 = rawDataString + rawDataString; + + const rpcHeaders = jsonRPCHeaders(rawDataString2); + assert.deepStrictEqual(rpcHeaders.headers.size, 3); + const rpcContent = jsonRPCContent(rpcHeaders.headers, rpcHeaders.remainingRawData); + assert.deepStrictEqual(rpcContent.extractedJSON, json); + assert.deepStrictEqual(rpcContent.remainingRawData, rawDataString); + }); +}); 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 b6be8d6081d..539647aece9 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 @@ -3,9 +3,10 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; import { TestController, TestItem, Uri } from 'vscode'; -import { IConfigurationService } from '../../../client/common/types'; +import { IConfigurationService, ITestOutputChannel } from '../../../client/common/types'; import { UnittestTestDiscoveryAdapter } from '../../../client/testing/testController/unittest/testDiscoveryAdapter'; import { UnittestTestExecutionAdapter } from '../../../client/testing/testController/unittest/testExecutionAdapter'; // 7/7 import { WorkspaceTestAdapter } from '../../../client/testing/testController/workspaceTestAdapter'; @@ -20,6 +21,7 @@ suite('Workspace test adapter', () => { let discoverTestsStub: sinon.SinonStub; let sendTelemetryStub: sinon.SinonStub; + let outputChannel: typemoq.IMock; let telemetryEvent: { eventName: EventName; properties: Record }[] = []; @@ -97,6 +99,7 @@ suite('Workspace test adapter', () => { discoverTestsStub = sandbox.stub(UnittestTestDiscoveryAdapter.prototype, 'discoverTests'); sendTelemetryStub = sandbox.stub(Telemetry, 'sendTelemetryEvent').callsFake(mockSendTelemetryEvent); + outputChannel = typemoq.Mock.ofType(); }); teardown(() => { @@ -109,8 +112,16 @@ suite('Workspace test adapter', () => { test("When discovering tests, the workspace test adapter should call the test discovery adapter's discoverTest method", async () => { discoverTestsStub.resolves(); - const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); - const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); // 7/7 + const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); + const testExecutionAdapter = new UnittestTestExecutionAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); const workspaceTestAdapter = new WorkspaceTestAdapter( 'unittest', testDiscoveryAdapter, @@ -134,8 +145,16 @@ suite('Workspace test adapter', () => { }), ); - const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); - const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); // 7/7 + const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); + const testExecutionAdapter = new UnittestTestExecutionAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); const workspaceTestAdapter = new WorkspaceTestAdapter( 'unittest', testDiscoveryAdapter, @@ -155,8 +174,16 @@ suite('Workspace test adapter', () => { test('If discovery succeeds, send a telemetry event with the "failed" key set to false', async () => { discoverTestsStub.resolves({ status: 'success' }); - const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); - const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); + const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); + const testExecutionAdapter = new UnittestTestExecutionAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); const workspaceTestAdapter = new WorkspaceTestAdapter( 'unittest', @@ -177,8 +204,16 @@ suite('Workspace test adapter', () => { test('If discovery failed, send a telemetry event with the "failed" key set to true, and add an error node to the test controller', async () => { discoverTestsStub.rejects(new Error('foo')); - const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter(stubTestServer, stubConfigSettings); - const testExecutionAdapter = new UnittestTestExecutionAdapter(stubTestServer, stubConfigSettings); + const testDiscoveryAdapter = new UnittestTestDiscoveryAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); + const testExecutionAdapter = new UnittestTestExecutionAdapter( + stubTestServer, + stubConfigSettings, + outputChannel.object, + ); const workspaceTestAdapter = new WorkspaceTestAdapter( 'unittest', diff --git a/extensions/positron-python/src/test/vscode-mock.ts b/extensions/positron-python/src/test/vscode-mock.ts index 9fb4bbec329..ebbe7ca59e7 100644 --- a/extensions/positron-python/src/test/vscode-mock.ts +++ b/extensions/positron-python/src/test/vscode-mock.ts @@ -112,6 +112,7 @@ mockedVSCode.FileSystemError = vscodeMocks.vscMockExtHostedTypes.FileSystemError mockedVSCode.LanguageStatusSeverity = vscodeMocks.LanguageStatusSeverity; mockedVSCode.QuickPickItemKind = vscodeMocks.QuickPickItemKind; mockedVSCode.InlayHint = vscodeMocks.InlayHint; +mockedVSCode.LogLevel = vscodeMocks.LogLevel; (mockedVSCode as any).NotebookCellKind = vscodeMocks.vscMockExtHostedTypes.NotebookCellKind; (mockedVSCode as any).CellOutputKind = vscodeMocks.vscMockExtHostedTypes.CellOutputKind; (mockedVSCode as any).NotebookCellRunState = vscodeMocks.vscMockExtHostedTypes.NotebookCellRunState; diff --git a/extensions/positron-python/tsconfig.extension.json b/extensions/positron-python/tsconfig.extension.json index aa77b4f7839..802f86c4d37 100644 --- a/extensions/positron-python/tsconfig.extension.json +++ b/extensions/positron-python/tsconfig.extension.json @@ -28,6 +28,7 @@ "src/client/**/*", "src/*", "typings/*.d.ts", + "typings/vscode-proposed/*.d.ts", "types/*.d.ts", "positron-dts/positron.d.ts" ] diff --git a/extensions/positron-python/tsconfig.json b/extensions/positron-python/tsconfig.json index 45db9d67509..a594636ff24 100644 --- a/extensions/positron-python/tsconfig.json +++ b/extensions/positron-python/tsconfig.json @@ -46,6 +46,7 @@ "src/*", "src/client/**/*", "typings/*.d.ts", + "typings/vscode-proposed/*.d.ts", "types/*.d.ts", "positron-dts/positron.d.ts" ] diff --git a/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts b/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts new file mode 100644 index 00000000000..b1176a9d46c --- /dev/null +++ b/extensions/positron-python/types/vscode.proposed.envCollectionWorkspace.d.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/171173 + + export interface EnvironmentVariableMutator { + readonly type: EnvironmentVariableMutatorType; + readonly value: string; + readonly scope: EnvironmentVariableScope | undefined; + } + + export interface EnvironmentVariableCollection extends Iterable<[variable: string, mutator: EnvironmentVariableMutator]> { + /** + * Sets a description for the environment variable collection, this will be used to describe the changes in the UI. + * @param description A description for the environment variable collection. + * @param scope Specific scope to which this description applies to. + */ + setDescription(description: string | MarkdownString | undefined, scope?: EnvironmentVariableScope): void; + replace(variable: string, value: string, scope?: EnvironmentVariableScope): void; + append(variable: string, value: string, scope?: EnvironmentVariableScope): void; + prepend(variable: string, value: string, scope?: EnvironmentVariableScope): void; + get(variable: string, scope?: EnvironmentVariableScope): EnvironmentVariableMutator | undefined; + delete(variable: string, scope?: EnvironmentVariableScope): void; + clear(scope?: EnvironmentVariableScope): void; + + } + + export type EnvironmentVariableScope = { + /** + * The workspace folder to which this collection applies to. If unspecified, collection applies to all workspace folders. + */ + workspaceFolder?: WorkspaceFolder; + }; +} diff --git a/extensions/positron-python/typings/vscode-proposed/vscode.proposed.quickPickItemTooltip.d.ts b/extensions/positron-python/typings/vscode-proposed/vscode.proposed.quickPickItemTooltip.d.ts new file mode 100644 index 00000000000..4e7d00fa5ed --- /dev/null +++ b/extensions/positron-python/typings/vscode-proposed/vscode.proposed.quickPickItemTooltip.d.ts @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + // https://github.com/microsoft/vscode/issues/73904 + + export interface QuickPickItem { + /** + * An optional flag to sort the final results by index of first query match in label. Defaults to true. + */ + tooltip?: string | MarkdownString; + } +} diff --git a/extensions/positron-python/yarn.lock b/extensions/positron-python/yarn.lock index d0fc09261a3..f5ee1031f31 100644 --- a/extensions/positron-python/yarn.lock +++ b/extensions/positron-python/yarn.lock @@ -382,66 +382,66 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@microsoft/1ds-core-js@3.2.9", "@microsoft/1ds-core-js@^3.2.8": - version "3.2.9" - resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.9.tgz#8a26935966e4871d1f1e40d992828bdd52bba84e" - integrity sha512-3pCfM2TzHn3gU9pxHztduKcVRdb/nzruvPFfHPZD0IM0mb0h6TGo2isELF3CTMahTx50RAC51ojNIw2/7VRkOg== +"@microsoft/1ds-core-js@3.2.11", "@microsoft/1ds-core-js@^3.2.9": + version "3.2.11" + resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.11.tgz#0a3857562a9bde7c15a2fa141c647f40aabd52e2" + integrity sha512-UuqZ1iWjaEFsBsnB+J0Q4IbgJdvJAq3LcNArAdMQQq9+wBWpjyGG4yu9gL6fS5AKfpF6yy73mtgWD7WzvphMLQ== dependencies: - "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-core-js" "2.8.13" "@microsoft/applicationinsights-shims" "^2.0.2" "@microsoft/dynamicproto-js" "^1.1.7" -"@microsoft/1ds-post-js@^3.2.8": - version "3.2.9" - resolved "https://registry.yarnpkg.com/@microsoft/1ds-post-js/-/1ds-post-js-3.2.9.tgz#07030f7455cb4ac8993e9b0bfa6c78ebfe25b499" - integrity sha512-D/RtqkQ2Nr4cuoGqmhi5QTmi3cBlxehIThJ1u3BaH9H/YkLNTKEcHZRWTXy14bXheCefNHciLuadg37G2Kekcg== +"@microsoft/1ds-post-js@^3.2.9": + version "3.2.11" + resolved "https://registry.yarnpkg.com/@microsoft/1ds-post-js/-/1ds-post-js-3.2.11.tgz#bbfc935bafb43982ccaf94ebd12f1fabf5ef8ebe" + integrity sha512-86prrN8cKjfpqfeyC4k0rFqg7fJDkTwPeKLHnkUgfq9mKVzu+BdtlEzaJtc56MroKWNYPkitZ/PWSwwpTPIgMA== dependencies: - "@microsoft/1ds-core-js" "3.2.9" + "@microsoft/1ds-core-js" "3.2.11" "@microsoft/applicationinsights-shims" "^2.0.2" "@microsoft/dynamicproto-js" "^1.1.7" -"@microsoft/applicationinsights-channel-js@2.8.10": - version "2.8.10" - resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-2.8.10.tgz#9d7077d7daa74d02d15bc26cc5440b4b5802cf5d" - integrity sha512-jXEUw3+U6WABygDOjEIlCLsniUpPqH5d/1Rfj1MVWMW6FFZo1vvYZoziOqb+dWWn41Dn5GF4EgXnvsfdkpz29w== +"@microsoft/applicationinsights-channel-js@2.8.13": + version "2.8.13" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-channel-js/-/applicationinsights-channel-js-2.8.13.tgz#bd117cc7b00ec929e74a6555cb7462ab4fccdf62" + integrity sha512-zc2BSsHk4HAqK5STdNzKGV817jKNbiTZPYpNt3zuE+jO5druJgloqrvclUfLnCoa7zwrQ2UxoAXlpJGmroGZPA== dependencies: - "@microsoft/applicationinsights-common" "2.8.10" - "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-common" "2.8.13" + "@microsoft/applicationinsights-core-js" "2.8.13" "@microsoft/applicationinsights-shims" "2.0.2" - "@microsoft/dynamicproto-js" "^1.1.7" + "@microsoft/dynamicproto-js" "^1.1.9" -"@microsoft/applicationinsights-common@2.8.10": - version "2.8.10" - resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-common/-/applicationinsights-common-2.8.10.tgz#927227db35e4448692726f68deb0f6af576b483c" - integrity sha512-wXji97I1eANL5PG8RxZ/st+HCwKgAB1uySSxEvVNj3VcOiUyTYTtBYYEK2xhjBGR49+A2/fIJQHvu1ygco2b3Q== +"@microsoft/applicationinsights-common@2.8.13": + version "2.8.13" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-common/-/applicationinsights-common-2.8.13.tgz#e3457bdc54a61cbfa481494da25e55bb9156096e" + integrity sha512-UYLLGVtuzrWUEmGYRroMzLyTi2fHqL6SwJUlmVWPJrmdK43PGpviRix/sBW0Qs+6qjiI1Z6CiG4Xah6w/HylhA== dependencies: - "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-core-js" "2.8.13" "@microsoft/applicationinsights-shims" "2.0.2" - "@microsoft/dynamicproto-js" "^1.1.7" + "@microsoft/dynamicproto-js" "^1.1.9" -"@microsoft/applicationinsights-core-js@2.8.10": - version "2.8.10" - resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-2.8.10.tgz#beb96a97a046ddb031d6adecf0d3143b635edf42" - integrity sha512-jQrufDW0+sV8fBhRvzIPNGiCC6dELH+Ug0DM5CfN9757TBqZJz8CSWyDjex39as8+jD0F/8HRU9QdmrVgq5vFg== +"@microsoft/applicationinsights-core-js@2.8.13": + version "2.8.13" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-core-js/-/applicationinsights-core-js-2.8.13.tgz#45c2b8fff35e5aa519355dd3a69e3758293b08f4" + integrity sha512-PP7Xjplvy0d5G2Tk7DcSDYRmgDYRv+7n3wEiqgm63DrSoa8rEuoODavjWunhX058zPNIeKbus59NE+DusLLyZg== dependencies: "@microsoft/applicationinsights-shims" "2.0.2" - "@microsoft/dynamicproto-js" "^1.1.7" + "@microsoft/dynamicproto-js" "^1.1.9" "@microsoft/applicationinsights-shims@2.0.2", "@microsoft/applicationinsights-shims@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-shims/-/applicationinsights-shims-2.0.2.tgz#92b36a09375e2d9cb2b4203383b05772be837085" integrity sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg== -"@microsoft/applicationinsights-web-basic@^2.8.9": - version "2.8.10" - resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-2.8.10.tgz#3bc52c2252a6dc41fa14679d941f7da0658d3676" - integrity sha512-Iay9y4eYxcX5vIrqbAuOx51hCqABopiQljGQjdxKO/aEET1nHrOxXxcrTUYGUJF/aYoR3+RxaiFcqcuujLPiOg== +"@microsoft/applicationinsights-web-basic@^2.8.11": + version "2.8.13" + resolved "https://registry.yarnpkg.com/@microsoft/applicationinsights-web-basic/-/applicationinsights-web-basic-2.8.13.tgz#a3934fa7b7f221d09fbf403fcfeb06a8c18ca246" + integrity sha512-DgPx1ryZucLWc285qkAEBG6LCcTSG6Gdb4u4yAlmAW0G+Qau49GoJnfJGR+cDXvyXSwHcH0dsainqzeYYY1K7A== dependencies: - "@microsoft/applicationinsights-channel-js" "2.8.10" - "@microsoft/applicationinsights-common" "2.8.10" - "@microsoft/applicationinsights-core-js" "2.8.10" + "@microsoft/applicationinsights-channel-js" "2.8.13" + "@microsoft/applicationinsights-common" "2.8.13" + "@microsoft/applicationinsights-core-js" "2.8.13" "@microsoft/applicationinsights-shims" "2.0.2" - "@microsoft/dynamicproto-js" "^1.1.7" + "@microsoft/dynamicproto-js" "^1.1.9" "@microsoft/applicationinsights-web-snippet@^1.0.1": version "1.0.1" @@ -453,6 +453,11 @@ resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.7.tgz#ede48dd3f85af14ee369c805e5ed5b84222b9fe2" integrity sha512-SK3D3aVt+5vOOccKPnGaJWB5gQ8FuKfjboUJHedMP7gu54HqSCXX5iFXhktGD8nfJb0Go30eDvs/UDoTnR2kOA== +"@microsoft/dynamicproto-js@^1.1.9": + version "1.1.9" + resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.9.tgz#7437db7aa061162ee94e4131b69a62b8dad5dea6" + integrity sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -734,10 +739,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.13.0.tgz#0400d1e6ce87e9d3032c19eb6c58205b0d3f7850" integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg== -"@types/node@^14.18.0": - version "14.18.36" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.36.tgz#c414052cb9d43fab67d679d5f3c641be911f5835" - integrity sha512-FXKWbsJ6a1hIrRxv+FoukuHnGTgEzKYGi7kilfMae96AL9UNkPFNWJEEYWzdRI9ooIkbr4AKldyuSTLql06vLQ== +"@types/node@^16.17.0": + version "16.18.25" + resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.25.tgz#8863940fefa1234d3fcac7a4b7a48a6c992d67af" + integrity sha512-rUDO6s9Q/El1R1I21HG4qw/LstTHCPO/oQNAwI/4b2f9EWvMnqt4d3HJwPMawfZ3UvodB8516Yg+VAq54YM+eA== "@types/semver@^5.5.0": version "5.5.0" @@ -868,15 +873,15 @@ resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== -"@vscode/extension-telemetry@^0.7.4-preview": - version "0.7.5" - resolved "https://registry.yarnpkg.com/@vscode/extension-telemetry/-/extension-telemetry-0.7.5.tgz#bf965731816e08c3f146f96d901ec67954fc913b" - integrity sha512-fJ5y3TcpqqkFYHneabYaoB4XAhDdVflVm+TDKshw9VOs77jkgNS4UA7LNXrWeO0eDne3Sh3JgURf+xzc1rk69w== +"@vscode/extension-telemetry@^0.7.7": + version "0.7.7" + resolved "https://registry.yarnpkg.com/@vscode/extension-telemetry/-/extension-telemetry-0.7.7.tgz#8213bfbdb1afa216befb146d6563ec5d200d7608" + integrity sha512-uW508BPjkWDBOKvvvSym3ZmGb7kHIiWaAfB/1PHzLz2x9TrC33CfjmFEI+CywIL/jBv4bqZxxjN4tfefB61F+g== dependencies: - "@microsoft/1ds-core-js" "^3.2.8" - "@microsoft/1ds-post-js" "^3.2.8" - "@microsoft/applicationinsights-web-basic" "^2.8.9" - applicationinsights "2.4.1" + "@microsoft/1ds-core-js" "^3.2.9" + "@microsoft/1ds-post-js" "^3.2.9" + "@microsoft/applicationinsights-web-basic" "^2.8.11" + applicationinsights "2.5.0" "@vscode/jupyter-lsp-middleware@^0.2.50": version "0.2.50" @@ -909,6 +914,34 @@ rimraf "^3.0.2" unzipper "^0.10.11" +"@vscode/vsce@^2.18.0": + version "2.19.0" + resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-2.19.0.tgz#342225662811245bc40d855636d000147c394b11" + integrity sha512-dAlILxC5ggOutcvJY24jxz913wimGiUrHaPkk16Gm9/PGFbz1YezWtrXsTKUtJws4fIlpX2UIlVlVESWq8lkfQ== + dependencies: + azure-devops-node-api "^11.0.1" + chalk "^2.4.2" + cheerio "^1.0.0-rc.9" + commander "^6.1.0" + glob "^7.0.6" + hosted-git-info "^4.0.2" + jsonc-parser "^3.2.0" + leven "^3.1.0" + markdown-it "^12.3.2" + mime "^1.3.4" + minimatch "^3.0.3" + parse-semver "^1.1.1" + read "^1.0.7" + semver "^5.1.0" + tmp "^0.2.1" + typed-rest-client "^1.8.4" + url-join "^4.0.1" + xml2js "^0.5.0" + yauzl "^2.3.1" + yazl "^2.2.2" + optionalDependencies: + keytar "^7.7.0" + "@webassemblyjs/ast@1.11.1": version "1.11.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" @@ -1210,10 +1243,10 @@ append-transform@^2.0.0: dependencies: default-require-extensions "^3.0.0" -applicationinsights@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-2.4.1.tgz#4de4c4dd3c7c4a44445cfbf3d15808fc0dcc423d" - integrity sha512-0n0Ikd0gzSm460xm+M0UTWIwXrhrH/0bqfZatcJjYObWyefxfAxapGEyNnSGd1Tg90neHz+Yhf+Ff/zgvPiQYA== +applicationinsights@2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-2.5.0.tgz#f008580b2f68267a5d233cce4e1f50b587bdf3c4" + integrity sha512-6kIFmpANRok+6FhCOmO7ZZ/mh7fdNKn17BaT13cg/RV5roLPJlA6q8srWexayHd3MPcwMb9072e8Zp0P47s/pw== dependencies: "@azure/core-auth" "^1.4.0" "@azure/core-rest-pipeline" "^1.10.0" @@ -4979,7 +5012,7 @@ json5@^2.1.2, json5@^2.2.2: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonc-parser@^3.0.0: +jsonc-parser@^3.0.0, jsonc-parser@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== @@ -8146,32 +8179,6 @@ vm-browserify@^1.0.1, vm-browserify@^1.1.2: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== -vsce@^2.6.6: - version "2.15.0" - resolved "https://registry.yarnpkg.com/vsce/-/vsce-2.15.0.tgz#4a992e78532092a34a755143c6b6c2cabcb7d729" - integrity sha512-P8E9LAZvBCQnoGoizw65JfGvyMqNGlHdlUXD1VAuxtvYAaHBKLBdKPnpy60XKVDAkQCfmMu53g+gq9FM+ydepw== - dependencies: - azure-devops-node-api "^11.0.1" - chalk "^2.4.2" - cheerio "^1.0.0-rc.9" - commander "^6.1.0" - glob "^7.0.6" - hosted-git-info "^4.0.2" - keytar "^7.7.0" - leven "^3.1.0" - markdown-it "^12.3.2" - mime "^1.3.4" - minimatch "^3.0.3" - parse-semver "^1.1.1" - read "^1.0.7" - semver "^5.1.0" - tmp "^0.2.1" - typed-rest-client "^1.8.4" - url-join "^4.0.1" - xml2js "^0.4.23" - yauzl "^2.3.1" - yazl "^2.2.2" - vscode-debugadapter-testsupport@^1.27.0: version "1.51.0" resolved "https://registry.yarnpkg.com/vscode-debugadapter-testsupport/-/vscode-debugadapter-testsupport-1.51.0.tgz#d60c4d9e2b70d094d9449abffdf3745898e698a4" @@ -8517,10 +8524,10 @@ ws@^7.3.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== -xml2js@^0.4.19, xml2js@^0.4.23: - version "0.4.23" - resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" - integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== +xml2js@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7" + integrity sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA== dependencies: sax ">=0.6.0" xmlbuilder "~11.0.0"