diff --git a/.github/actions/run-linux-tests/action.yml b/.github/actions/run-linux-tests/action.yml new file mode 100644 index 000000000..81edac483 --- /dev/null +++ b/.github/actions/run-linux-tests/action.yml @@ -0,0 +1,39 @@ +name: 'Run Linux extension tests' +description: 'Run tests under Xvfb and retain startup diagnostics on failure' + +inputs: + script: + description: 'npm test script to run' + required: true + artifact-name: + description: 'Unique diagnostic artifact name for this job' + required: true + +runs: + using: 'composite' + steps: + - name: Install Xvfb + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y xvfb x11-xserver-utils + + - name: Run tests with diagnostics + id: tests + shell: bash + env: + TEST_SCRIPT: ${{ inputs.script }} + VSCODE_TEST_DIAGNOSTICS_DIR: ${{ runner.temp }}/vscode-test-diagnostics + run: | + mkdir -p "$VSCODE_TEST_DIAGNOSTICS_DIR" + xvfb-run --auto-servernum --error-file="$VSCODE_TEST_DIAGNOSTICS_DIR/xvfb.log" \ + npm run "$TEST_SCRIPT" 2>&1 | tee "$VSCODE_TEST_DIAGNOSTICS_DIR/launcher.log" + + - name: Upload failure diagnostics + if: ${{ failure() && steps.tests.outcome == 'failure' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.artifact-name }} + path: ${{ runner.temp }}/vscode-test-diagnostics + if-no-files-found: error + retention-days: 7 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c48305b7..88d35338b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,4 +8,5 @@ updates: schedule: interval: "weekly" cooldown: - default-days: 7 + # Delay updates beyond the highest-risk window after a release. + default-days: 14 diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index 6eeac8aa3..4582a44ce 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -20,6 +20,8 @@ This guide covers the full testing lifecycle: - Pip commands that return JSON must pass `--disable-pip-version-check`; the process helper combines stderr with stdout, so update notices can otherwise make valid JSON unparseable (1). - When a view subscribes to a newly added provider event, TypeMoq-based view tests must return a real `EventEmitter.event`; an unstubbed event yields an undefined disposable and fails during teardown (1). +- Test agent selections across a full reload with `python.defaultInterpreterPath` set. An effective manager value equal to the extension default does not prove a workspace value was saved; tool-owned persistence must inspect `workspaceValue` or startup can restore the global interpreter (1). +- `vscode.executeCodeLensProvider` verifies provider output, not visible editor refresh. VS Code cancels its debounced CodeLens refresh when the editor loses focus; account for background UI automation restoring focus to another app before diagnosing a stale rendered lens (1). ### When to Use This Guide @@ -614,3 +616,4 @@ envConfig.inspect - **pet binary is required for environment manager registration**: The smoke/E2E/integration tests require the `pet` binary from `microsoft/python-environment-tools` to be built and placed in `python-env-tools/bin/`. Without it, `waitForApiReady()` will timeout because managers never register. CI must build pet from source using `cargo build --release --package pet` (2) - **Check exact project registration with `getPythonProjects()`**: `getPythonProject(uri)` can return a containing parent project, so it cannot prove that a nested project was registered or unregistered (1) - **Use controlled fixture providers for package lifecycle integration tests**: Discovering an existing Conda environment does not guarantee API quick-create can succeed. Use `createEnvironmentFixture()` so creation prerequisites and cleanup are deterministic (1) +- **Retry Windows integration-test cleanup at the filesystem operation, not the suite**: After closing VS Code editors, use bounded `fs.rm(..., { recursive: true, force: true, maxRetries, retryDelay })` for temporary workspaces because Windows can briefly return `EBUSY`; keep final failures visible instead of adding sleeps or Mocha retries (1) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 7fcdf503a..68e6f657f 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -82,7 +82,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -141,6 +141,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -153,9 +154,10 @@ jobs: - name: Run Smoke Tests (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run smoke-test + script: smoke-test + artifact-name: smoke-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run Smoke Tests (non-Linux) if: runner.os != 'Linux' @@ -169,7 +171,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -228,6 +230,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -240,9 +243,10 @@ jobs: - name: Run E2E Tests (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run e2e-test + script: e2e-test + artifact-name: e2e-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run E2E Tests (non-Linux) if: runner.os != 'Linux' @@ -259,7 +263,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -318,6 +322,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -336,9 +341,10 @@ jobs: - name: Run Integration Tests (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run integration-test + script: integration-test + artifact-name: integration-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' @@ -352,7 +358,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -368,6 +374,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -380,9 +387,10 @@ jobs: - name: Run Integration Tests Multi-Root (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run integration-test-multiroot + script: integration-test-multiroot + artifact-name: integration-multiroot-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run Integration Tests Multi-Root (non-Linux) if: runner.os != 'Linux' diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index de4483dd9..ba6cd8a81 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -83,7 +83,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -142,6 +142,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -154,9 +155,10 @@ jobs: - name: Run Smoke Tests (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run smoke-test + script: smoke-test + artifact-name: smoke-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run Smoke Tests (non-Linux) if: runner.os != 'Linux' @@ -170,7 +172,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -229,6 +231,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -241,9 +244,10 @@ jobs: - name: Run E2E Tests (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run e2e-test + script: e2e-test + artifact-name: e2e-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run E2E Tests (non-Linux) if: runner.os != 'Linux' @@ -260,7 +264,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.10', '3.12', '3.14'] + python-version: ['3.10', '3.12', '3.14', '3.15'] steps: - name: Checkout @@ -319,6 +323,7 @@ jobs: uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} + allow-prereleases: true - name: Install Dependencies run: npm ci @@ -337,9 +342,10 @@ jobs: - name: Run Integration Tests (Linux) if: runner.os == 'Linux' - uses: GabrielBB/xvfb-action@b706e4e27b14669b486812790492dc50ca16b465 # v1.7 + uses: ./.github/actions/run-linux-tests with: - run: npm run integration-test + script: integration-test + artifact-name: integration-${{ matrix.os }}-${{ matrix.python-version }}-${{ github.run_attempt }} - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 23d18eb94..445b6eb61 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -7,6 +7,13 @@ import * as path from 'path'; // VS Code creates `/-main.sock`. An in-workspace location // (e.g. /Users/runner/work///.vscode-test/user-data) overflows. const userDataDir = path.join(os.tmpdir(), 'vsct-ud'); +const diagnosticsDir = process.env.VSCODE_TEST_DIAGNOSTICS_DIR; +const diagnosticArgs = diagnosticsDir + ? [`--logsPath=${path.join(diagnosticsDir, 'vscode')}`, '--verbose'] + : []; +const diagnosticMocha = diagnosticsDir + ? { require: [path.resolve('out', 'test', 'diagnostics.js')] } + : {}; // Seed user settings.json so the extension actually activates: without // `python.useEnvironmentsExtension=true` it short-circuits during activation @@ -23,6 +30,7 @@ export default defineConfig([ label: 'smokeTests', files: 'out/test/smoke/**/*.smoke.test.js', mocha: { + ...diagnosticMocha, ui: 'tdd', timeout: 120000, }, @@ -30,6 +38,7 @@ export default defineConfig([ VSC_PYTHON_SMOKE_TEST: '1', }, launchArgs: [ + ...diagnosticArgs, `--user-data-dir=${userDataDir}`, // Don't open any folder with Python files to prevent premature activation '--disable-workspace-trust', @@ -43,6 +52,7 @@ export default defineConfig([ label: 'e2eTests', files: 'out/test/e2e/**/*.e2e.test.js', mocha: { + ...diagnosticMocha, ui: 'tdd', timeout: 180000, }, @@ -50,6 +60,7 @@ export default defineConfig([ VSC_PYTHON_E2E_TEST: '1', }, launchArgs: [ + ...diagnosticArgs, `--user-data-dir=${userDataDir}`, '--disable-workspace-trust', ], @@ -62,6 +73,7 @@ export default defineConfig([ files: 'out/test/integration/*.integration.test.js', workspaceFolder: 'src/test/integration/test-workspace/project-a', mocha: { + ...diagnosticMocha, ui: 'tdd', timeout: 60000, }, @@ -69,6 +81,7 @@ export default defineConfig([ VSC_PYTHON_INTEGRATION_TEST: '1', }, launchArgs: [ + ...diagnosticArgs, `--user-data-dir=${userDataDir}`, '--disable-workspace-trust', ], @@ -81,6 +94,7 @@ export default defineConfig([ files: 'out/test/integration/multiroot/*.integration.test.js', workspaceFolder: 'src/test/integration/test-workspace/integration-tests.code-workspace', mocha: { + ...diagnosticMocha, ui: 'tdd', timeout: 60000, retries: 1, @@ -89,6 +103,7 @@ export default defineConfig([ VSC_PYTHON_INTEGRATION_TEST: '1', }, launchArgs: [ + ...diagnosticArgs, `--user-data-dir=${userDataDir}`, '--disable-workspace-trust', ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c352fdc0..9ab6527a0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,6 +81,27 @@ Thank you for your interest in contributing to the Python Environments extension ### Testing Run unit tests with the different configurations in the "Run and Debug" panel +#### Linux CI failure diagnostics + +Failed Linux smoke, E2E, and integration test launches upload a diagnostic artifact +named for the suite, OS, Python version, and run attempt. Artifacts are retained for +seven days and contain: + +- `launcher.log`: test-launcher output, including the downloaded VS Code version + and verbose VS Code startup output. +- `xvfb.log`: X server stderr, kept separately from test failures. +- `vscode/`: VS Code main, renderer, and extension-host logs, when startup reaches + those processes. +- `test-runner-started.json`: written when the extension-host test runner reaches + its global setup, before loading test suites; includes the VS Code version and + extension-host arguments. If absent, the failure occurred before this marker + could be written, not necessarily inside a test. + +The shared [Linux test action](.github/actions/run-linux-tests/action.yml) enables +these diagnostics via `VSCODE_TEST_DIAGNOSTICS_DIR`. Local runs remain unchanged +unless that variable is set to an existing directory. Diagnostics do not add +retries or suppress test failures. + ## Contributor License Agreement (CLA) This project requires contributors to sign a Contributor License Agreement (CLA). When you submit a pull request, a CLA bot will automatically check if you need to provide a CLA and guide you through the process. You only need to do this once across all Microsoft repositories. @@ -98,6 +119,152 @@ The npm package under [`api/`](./api) is the public API facade other extensions - `src/api.ts`, `src/types.ts`, and `src/publicErrors.ts` are validated on every PR by the extension's own lint and TypeScript compile. - **Versioning and compatibility:** the published package version in [`api/package.json`](./api/package.json) is maintained independently of the extension version in [`package.json`](./package.json) — the two do not need to match. Compatibility is based on the API shape exported by the installed Python Environments extension at runtime. Package updates must preserve backwards-compatible contracts unless the API package version intentionally communicates a breaking change; consumers should treat newly added members as optional when they may run against older installed extension versions. Any PR that edits `src/api.ts`, `src/types.ts`, or `src/publicErrors.ts` must bump `api/package.json` (use the `skip api version` label to bypass) and add an entry to [`api/CHANGELOG.md`](./api/CHANGELOG.md) (use the `skip api changelog` label to bypass). +## Internal Python agent-tool bridge + +The extension's existing **flat** export also provides `__pythonTools.version === 1`. +This is an internal convention, not a security boundary or a supported public API. +Its types live only in `src/internal/pythonToolsApi.ts`; do not copy them into +`src/types.ts`, `src/api.ts`, or the published API package. Consumers must check the +version and all three methods at runtime before using the private API. The Python +consumer uses its previous public Environments integration when that capability +is absent or incompatible; that compatibility route can still prompt. It never +falls back after a private operation starts, including errors, partial results, +timeouts, or cancellation. Environments-disabled users retain the legacy Python +route. +Explicit compatibility selection persists through the public Environments setter, not +just the Python extension's cached interpreter path, so later package operations +use the same environment. Reusing an already-selected environment is read-only; +it does not reselect or wait for an interpreter-change event. + +| Method | Request | Result | +| --- | --- | --- | +| `configureEnvironment(request, token)` | `{ resourcePath?: string, pythonPath?: string }` | Select an explicit interpreter exactly; otherwise reuse an isolated environment or create one for the project. Success includes `created`. | +| `getEnvironment(request, token)` | `{ resourcePath?: string, includePackages?: boolean }` | Resolve the real selected environment, not the public API's short-timeout cache. `includePackages: true` returns `{ name, version?: string }[]`, including `[]`, or an error. | +| `installPackages(request, token)` | `{ resourcePath?: string, packages: string[] }` | Install into an isolated environment. Does not create or change selection implicitly, or modify any global/base interpreter. | + +Automatic configuration uses isolation by default, regardless of whether a global +interpreter was selected automatically or by a person. Virtual environments are +recognized by `pyvenv.cfg`; non-base Conda and inline-script environments are also +isolated. Existing isolated environments outside the workspace remain reusable. +When no isolated environment is selected, configuration can resolve Poetry/Pipenv +caches through the native finder's project association. It does not infer ownership +from the cache location or record selection history; ambiguous project matches +require an explicit `pythonPath`. Read-only queries do not select these candidates. +There is no selection-origin bookkeeping or persistence. Old origin records are +ignored; ordinary current-interpreter selection and its persistence are unchanged. +An explicit `pythonPath` selects that interpreter without creating an environment. +This can select global Python for execution or queries, but does not authorize +package installation into it: `ENVIRONMENT_NOT_ISOLATED` directs the caller to +configure without `pythonPath` or select an existing isolated environment. + +All methods require a VS Code `CancellationToken` and a trusted workspace. +`resourcePath` accepts an absolute path or a `file:` URI within an open workspace. +The Python tools default an omitted or empty path to the first workspace folder and +pass it explicitly. They disclose the target in preparation/results and instruct agents +to reuse the returned `resourcePath`. Editor focus does not change this default. +At the direct private API boundary, omission selects the only workspace root; +zero/multiple roots still produce actionable errors. The consumer default does not +relax that backend validation. +Other explicit input is never replaced by that default. Because the route is +workspace scoped, read-only tools keep their previous Environments behaviour when +no folder is open. Configuration and installation with a compatible private API +report `NO_WORKSPACE` instead of opening a picker or modifying global Python. +Nested projects and per-script selections retain their scope; a file is +never used as an environment creation directory. There is no User/global settings +fallback. An explicit `pythonPath` can switch away from an unavailable manager. +New nested targets persist as exact project entries without changing root defaults; +their automatic dependency discovery is restricted to that target directory. +Tool selections at the workspace root persist an explicit workspace manager, even +when it matches the extension default. This prevents `python.defaultInterpreterPath` +from restoring global Python on reload without rewriting that interpreter setting. +Ordinary human selections retain their existing settings-write behavior. + +Results are `{ status: 'success', environment, resourcePath?, created?, packages? }` +or `{ status: 'error', code, message, environment?, resourcePath? }`. Errors retain +an already-created environment when subsequent package installation or selection +fails; retries reuse a resolvable `.venv`/`.conda` rather than creating a suffixed +directory. Selecting an existing environment does not reinstall project +dependencies. A resolvable `.venv`/`.conda` is only reused when it actually contains +the resolved isolated environment. A new subdirectory can reuse an isolated parent-project +selection; `resourcePath` chooses the target, not a requirement to create a fresh +environment. +Register an independent project when it needs a separate selection. +Reads and writes for the same effective target share a cancellable +queue; waiting behind an operation does not use the discovery timeout. Different +targets remain independent. Discovery timeouts report `NOT_READY`; a configured +environment manager that never registers reports `UNSUPPORTED_MANAGER` naming the id; +subprocess +timeouts report `TIMEOUT`. Cancellation throws `CancellationError` after owned +process cleanup. Cancellation is cooperative, not a rollback: completed writes or +package changes are not undone. A completed action remains successful if a +cancellation request arrives on its return. If cleanup fails, +`PROCESS_TERMINATION_FAILED` takes precedence: +the process may still be modifying files, so consumers must not blindly retry. +Cleanup waits for process closure and owned POSIX group completion; an exited +Windows parent cannot turn a failed tree termination into successful cancellation. +If a Windows parent has already exited when cancellation starts, descendant +cleanup cannot be confirmed; the tool reports that uncertainty instead of +signalling a potentially reused PID. +Only internal `toolExecution` calls enable these subprocess policies. Public +progress tokens do not enable agent timeouts, input suppression, or process-tree +cleanup semantics. Public package calls retain their existing execution defaults. + +Internal symbol capabilities opt built-in managers into prompt-free operations; +they are unrelated to the human UI's `quickCreateConfig`. Creation supports venv, +Conda, and enabled PEP 723 inline scripts (the latter retain their existing cache +ownership/metadata rules). System and Pyenv base interpreters route creation to +venv; Conda base routes to an isolated Conda prefix. Poetry and Pipenv environments +can be reused, but unsupported creation and contributed managers without this +capability fail explicitly. Private package operations use the existing headless pip/uv, Conda, and +Poetry helpers with the real token and strict, uncached package listing. Venv +dependency validation fails rather than asking to continue. No tool installs a +base Python automatically. Conda response descriptors clone `execInfo.activatedRun` +to a standalone `conda run --prefix ... --no-capture-output ...` command; shared +public descriptors are not changed. +Venv creation uses the selected Python 3 as its base, falling back to the latest +discovered Python 3 only when none is selected. Conda creation requests the selected +Python major/minor version. This does not add a project version-constraint resolver +for `pyproject.toml` or `.python-version`. To choose a particular base, select it +first and then configure without `pythonPath`; the path parameter itself is an +exact selection request, not a venv base-interpreter argument. +Bare Conda commands configured through `python.condaPath` are resolved through +PATH for private execution and returned command prefixes. +Conda base is isolated even when inherited through `CONDA_PREFIX`, and failed +Conda discovery does not authorize replacement creation. Public Conda refresh remains best-effort while +the private readiness check retains the discovery error. Poetry verifies the +requested project's actual environment prefix before package operations. Agent +inventory uses `poetry run pip list --format=json`, including when Poetry supplies +pip itself; the public lockfile-based `poetry show` path is unchanged. +Private Poetry installs also apply the lockfile with `install --no-root`, since +`add` alone skips already-declared dependencies missing from the environment. +Direct installs into shared PEP 723 +caches return `IMMUTABLE_ENVIRONMENT`; edit script metadata and configure again. +With the inline-script feature enabled, configuring a Python file with PEP 723 +metadata can create its cache on the first tool invocation, without first using a +CodeLens or registering a script project manually. Invalid metadata fails rather +than falling back to ordinary project creation. This opt-in configuration route +does not change human or read-only routing, which still requires a validated +association. Target the project directory or supply `pythonPath` for ordinary +project/exact-interpreter configuration instead. +An agent joining a human-owned inline cache build gets `ENVIRONMENT_BUSY` without +waiting for or cancelling the human operation. + +Private discovery is separate from public initialization and its onboarding. +Agent reads never start or await installation or missing-manager questions, even +when a concurrent public initialization is waiting for input. Ordinary startup, +public API, and human workflows retain their prompts; independently triggered +startup UI can still appear while a tool runs. There is no global "suppress UI" +switch. Exact nested-project persistence is also opt-in to the private setter; +ordinary manager-setting updates retain their existing defaults. +Human venv initialization retries discovery and base preparation after a failure, +including failures after discovery has completed. A partial initialization is not +reused as a successful cache on the next public lookup; private discovery still +does not wait for human onboarding. + +Keep tests for both internal and human routes when changing these helpers. The +focused suites are in `src/test/internal`, with shared creation/package and +inline-script regressions under `src/test/managers`. + ## Questions or Issues? - **Questions**: Start a [discussion](https://github.com/microsoft/vscode-python/discussions/categories/q-a) diff --git a/README.md b/README.md index 2d4c539e3..daa6ac77e 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ All commands can be accessed via the Command Palette (`ctrl/cmd + Shift + P`): | pythonProjects | `[]` | A list of Python workspaces, specified by the path, in which you can set particular environment and package managers. You can set information for a workspace as `[{"path": "/path/to/workspace", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip"]}`. | | terminal.showActivateButton | `false` | (experimental) Show a button in the terminal to activate/deactivate the current environment for the terminal. This button is only shown if the active terminal is associated with a project that has an activatable environment. | | terminal.autoActivationType | `"command"` | Specifies how the extension can activate an environment in a terminal. Accepted values: `command` (execute activation command in terminal), `shellStartup` (`terminal.integrated.shellIntegration.enabled` successfully enabled or we may modify shell startup scripts ), `off` (no auto-activation). Shell startup is only supported for: zsh, fish, pwsh, bash, cmd. **Takes precedence over** `python.terminal.activateEnvironment`. Restart terminals after changing this setting. To revert shell startup changes, run `Python Envs: Revert Shell Startup Script Changes`. | -| alwaysUseUv | `true` | When `true`, [uv](https://github.com/astral-sh/uv) will be used to manage all virtual environments if available. When `false`, uv will only manage virtual environments explicitly created by uv. The extension prefers `uv` on its PATH; if unavailable in a trusted workspace, it also checks that workspace's `.pyprojectx/main/uv` (`uv.exe` on Windows) for environments inside that workspace. External environments still require uv on PATH. This does not alter the integrated terminal's PATH. | +| alwaysUseUv | `true` | When `true`, [uv](https://github.com/astral-sh/uv) will be used to manage all virtual environments if available. When `false`, uv will only manage virtual environments explicitly created by uv. The extension uses `uv` on its PATH, or the executable it installed during the current session. If unavailable in a trusted workspace, it also checks that workspace's `.pyprojectx/main/uv` (`uv.exe` on Windows) for environments inside that workspace. External environments require uv on PATH or a current-session installation. This does not alter the integrated terminal's PATH. | | globalSearchPaths | `[]` | Global search paths for Python environments. Array of absolute directory paths to search for environments at the user level. This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. | | workspaceSearchPaths | `[]` | Workspace search paths for Python environments. Can be absolute paths or relative directory paths searched within the workspace. | diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index eb86291b4..00c157869 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `@vscode/python-environments` API package are documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.6.0] + +### Added + +- Added optional `EnvironmentManager.getVersion?(): Promise` support for retrieving the version of the underlying environment management tool. Built-in implementations are provided for Conda, Poetry, Pipenv, and Pyenv. + ## [1.5.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 62fda10a8..3be873d42 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.5.0", + "version": "1.6.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" @@ -77,9 +77,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/api/package.json b/api/package.json index ada2db299..b2be12d99 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.5.0", + "version": "1.6.0", "author": { "name": "Microsoft Corporation" }, diff --git a/docs/README.md b/docs/README.md index bbeb1b09a..7fd0ce7d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -734,9 +734,9 @@ const c: PackageManagementOptions = { #### `Pep440Version` Re-exported from `@renovatebot/pep440`. Represents a parsed PEP 440 version, -returned by `getPackageAvailableVersions` and by a package manager's -`getVersion`. Import it from `@vscode/python-environments` so your types match -the API exactly. +returned by `getPackageAvailableVersions` and by environment and package +managers' `getVersion` methods. Import it from +`@vscode/python-environments` so your types match the API exactly. | Field | Type | Required | Description | | --- | --- | --- | --- | @@ -1556,6 +1556,7 @@ trigger, as the specification. | `tooltip` | `string \| MarkdownString` | No | Hover text for the manager. | | `iconPath` | [`IconPath`](#iconpath) | No | Icon shown for the manager. | | `log` | `LogOutputChannel` | No | Output channel used for the manager's logs. | +| `getVersion()` | `() => Promise` | No | Returns the parsed PEP 440 version of the underlying environment management tool. Return `undefined` when the manager has no versioned tool or its version is unavailable. | | `getEnvironments(scope)` | `(scope: GetEnvironmentsScope) => Promise` | Yes | Returns the environments known for the scope. Called frequently by UI surfaces. | | `refresh(scope)` | `(scope: RefreshEnvironmentsScope) => Promise` | Yes | Re-discovers environments for the scope. | | `set(scope, environment?)` | `(scope: SetEnvironmentScope, environment?: PythonEnvironment) => Promise` | Yes | Sets or clears the active environment for the scope. Also called at startup to rehydrate persisted state. | @@ -1576,6 +1577,10 @@ class MyEnvManager implements EnvironmentManager { constructor(private readonly api: PythonEnvironmentApi) {} + async getVersion(): Promise { + return getMyManagerVersion(); + } + async getEnvironments( scope: GetEnvironmentsScope, ): Promise { diff --git a/docs/managing-python-projects.md b/docs/managing-python-projects.md index f5e4cecb1..feed6ab6b 100644 --- a/docs/managing-python-projects.md +++ b/docs/managing-python-projects.md @@ -101,7 +101,9 @@ When you create a script, the extension generates a single `.py` file with PEP 7 An inline-script environment is built from the script's `# /// script` block and stored in the extension's cache, where it is shared by every script with the same dependencies and base interpreter. Because editing one would silently change the others, these environments are not user-managed: the Python Environments views do not offer install, uninstall, or version-change actions for them. Their package list remains visible. -A CodeLens above the `# /// script` block offers **Set up environment for this script**, and the same action is available as a quick fix on an unresolved import. For a few seconds after setup succeeds it is replaced by a **Script environment ready (Python X.Y.Z)** confirmation naming the Python that was selected — useful when `requires-python` matches several installed versions, or when one was installed on demand. The confirmation is plain text rather than a clickable action, and it expires on its own; at every other time the setup CodeLens behaves exactly as before. +A CodeLens stays above the `# /// script` block, including while its metadata is incomplete, malformed, or unsaved. It offers **Set up environment for this script** until the block matches a validated environment, then displays **Script environment ready (Python X.Y.Z)** as persistent, non-clickable text. The ready label also appears for environments restored after reopening VS Code. + +The CodeLens follows the live block text: editing the block offers setup immediately without requiring a save, while editing Python code outside it does not change its fingerprint. Diagnostics continue to explain malformed metadata as you type; a warning popup appears only if you try to set up an invalid block. Setup saves valid unsaved changes before creating the environment. Execution and interpreter routing still use validated, saved metadata, and saving can restore a matching existing association without rebuilding it. Setup records which distributions it installed. If that record and the environment's contents later disagree — for example after installing a package into it from a terminal — every script sharing the environment needs setup again. Saving or reopening a script does not repair it; use the script's setup action to rebuild from its declared dependencies. diff --git a/package-lock.json b/package-lock.json index 10ad93a0d..3643bb17a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-python-envs", - "version": "1.39.0", + "version": "1.40.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "vscode-python-envs", - "version": "1.39.0", + "version": "1.40.0", "dependencies": { "@iarna/toml": "^2.2.5", "@renovatebot/pep440": "^4.2.4", @@ -4873,9 +4873,9 @@ } }, "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", "dev": true, "funding": [ { @@ -4887,10 +4887,11 @@ "url": "https://github.com/sponsors/markdown-it" } ], + "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -10934,14 +10935,14 @@ } }, "markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", "dev": true, "requires": { "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" diff --git a/package.json b/package.json index 2fb66179b..c68a525db 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vscode-python-envs", "displayName": "Python Environments", "description": "Provides a unified python environment experience", - "version": "1.39.0", + "version": "1.40.0", "publisher": "ms-python", "engines": { "vscode": "^1.110.0-20260204" diff --git a/src/common/inlineScript/block.ts b/src/common/inlineScript/block.ts new file mode 100644 index 000000000..bf4892b10 --- /dev/null +++ b/src/common/inlineScript/block.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export interface InlineScriptBlock { + readonly start: number; + readonly end: number; +} + +/** + * Locate the first recognizable script opener and its next closing marker, without validation. + * Incomplete blocks are included so editor actions remain available while typing. + * This approximate presentation range must not be used for metadata validation or fingerprints. + */ +export function findInlineScriptBlock(text: string): InlineScriptBlock | undefined { + const opener = /^[\t \uFEFF]*#[\t ]*\/\/\/[\t ]+script\b[^\r\n]*/gm.exec(text); + if (!opener) { + return undefined; + } + const closerPattern = /^[\t ]*#[\t ]*\/\/\/[\t ]*$/gm; + closerPattern.lastIndex = opener.index + opener[0].length; + const closer = closerPattern.exec(text); + return { + start: opener.index + (opener.index === 0 && text.startsWith('\uFEFF') ? 1 : 0), + end: closer ? closer.index + closer[0].length : text.length, + }; +} diff --git a/src/common/inlineScript/metadata.ts b/src/common/inlineScript/metadata.ts index 7dad32fa2..b0c22393f 100644 --- a/src/common/inlineScript/metadata.ts +++ b/src/common/inlineScript/metadata.ts @@ -2,8 +2,9 @@ // Licensed under the MIT License. import * as tomljs from '@iarna/toml'; +import { createHash } from 'crypto'; import * as fs from 'fs/promises'; -import { Uri } from 'vscode'; +import { l10n, Uri } from 'vscode'; import { traceVerbose, traceWarn } from '../logging'; import { PythonVersion } from '../pythonVersion'; import { PythonVersionSpecifier } from '../pythonVersionSpecifier'; @@ -29,6 +30,8 @@ export interface InlineScriptMetadata { */ readonly range: { readonly start: number; readonly end: number }; readonly sourceRange?: { readonly start: number; readonly end: number }; + /** Saved block fingerprint for edit detection and presentation, separate from semantic/cache identity. */ + readonly sourceHash?: string; } /** @@ -70,6 +73,24 @@ export type InlineScriptMetadataParseResult = | { readonly kind: 'none' } | { readonly kind: 'invalid'; readonly problems: readonly InlineScriptMetadataProblem[] }; +/** + * Explain why parsed metadata cannot be used by the interactive setup action. + * This presentation check does not change the parser's tolerant metadata or routing contract. + */ +export function getInlineScriptSetupProblem( + result: InlineScriptMetadataParseResult, +): 'metadata' | 'requires-python' | undefined { + if ( + result.kind !== 'parsed' || + result.problems.length > 0 || + result.metadata.dependencies?.some((dependency) => dependency.trim().length === 0) + ) { + return 'metadata'; + } + const requirement = result.metadata.requiresPython?.trim(); + return requirement && !PythonVersionSpecifier.tryParse(requirement) ? 'requires-python' : undefined; +} + /** * Canonical block regex from the PEP 723 spec, translated to JavaScript * (Python's `(?P...)` becomes `(?...)` in JS). The flag @@ -122,6 +143,57 @@ const NO_METADATA: InlineScriptMetadataParseResult = { kind: 'none' }; const OPENER_PREFIX = '# /// '; +/** + * Fingerprint the same structural script block selected by the metadata parser, without parsing TOML, + * logging, or I/O. Malformed markers return undefined; ignored body examples do not affect the hash. + * Normalize BOM/line endings as the parser does so disk and editor representations compare equally. + */ +export function getInlineScriptSourceHash(scriptText: string): string | undefined { + const text = scriptText.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); + const { scriptMatches, problems } = scanInlineScriptBlocks( + text, + (start, end) => ({ start, end }), + '', + false, + ); + return scriptMatches.length === 1 && problems.length === 0 + ? hashScriptBlock(scriptMatches[0][0]) + : undefined; +} + +function hashScriptBlock(block: string): string { + return createHash('sha256').update(block, 'utf8').digest('hex'); +} + +function scanInlineScriptBlocks( + text: string, + toSourceRange: (start: number, end: number) => { start: number; end: number }, + where: string, + logProblems = true, +): { scriptMatches: RegExpMatchArray[]; problems: InlineScriptMetadataProblem[] } { + const scriptMatches: RegExpMatchArray[] = []; + // matchAll leaves the shared regex's lastIndex untouched. + for (const match of text.matchAll(BLOCK_RE)) { + if (match.groups?.type === 'script') { + scriptMatches.push(match); + } + } + const matchedRanges = scriptMatches.map((match) => ({ start: match.index!, end: match.index! + match[0].length })); + const problems: InlineScriptMetadataProblem[] = []; + const headerEnd = headerRegionEnd(text); + for (const opener of findScriptOpeners(text)) { + if (matchedRanges.some((range) => opener.offset >= range.start && opener.offset < range.end)) { + continue; + } + const { problem, ignorableBelowHeader } = diagnoseMalformedBlock(text, opener, toSourceRange, where, logProblems); + if (ignorableBelowHeader && opener.offset >= headerEnd) { + continue; + } + problems.push(problem); + } + return { scriptMatches, problems }; +} + /** As `readInlineScriptMetadata`, but reports why and where parsing failed. Offsets index the original `scriptText`. */ export function parseInlineScriptMetadata(scriptText: string, source?: string): InlineScriptMetadataParseResult { const where = source ? ` in ${source}` : ''; @@ -147,37 +219,7 @@ export function parseInlineScriptMetadata(scriptText: string, source?: string): end: bomOffset + sourceOffsetForNormalizedOffset(sourceText, end), }); - // Collect ALL matches first so we can detect the "multiple script - // blocks" error case the spec requires us to surface. - // - // `matchAll` constructs a fresh iterator and does not mutate the - // shared `BLOCK_RE.lastIndex`, so this loop is re-entrant and safe - // even if a caller (or an exception) ever interrupts a previous - // pass. - const scriptMatches: RegExpMatchArray[] = []; - for (const m of text.matchAll(BLOCK_RE)) { - // Per spec, tools MUST NOT read non-standardized block types. - // The only standardized type today is `script`. - if (m.groups?.type === 'script') { - scriptMatches.push(m); - } - } - - const matchedRanges = scriptMatches.map((m) => ({ start: m.index!, end: m.index! + m[0].length })); - const problems: InlineScriptMetadataProblem[] = []; - const headerEnd = headerRegionEnd(text); - for (const opener of findScriptOpeners(text)) { - if (matchedRanges.some((r) => opener.offset >= r.start && opener.offset < r.end)) { - continue; - } - const { problem, ignorableBelowHeader } = diagnoseMalformedBlock(text, opener, toSourceRange, where); - // Blocks the spec tells us to ignore are only worth flagging in the leading comment - // region, where they are a header being typed rather than a documentation example. - if (ignorableBelowHeader && opener.offset >= headerEnd) { - continue; - } - problems.push(problem); - } + const { scriptMatches, problems } = scanInlineScriptBlocks(text, toSourceRange, where); if (scriptMatches.length === 0) { if (problems.length === 0) { @@ -368,7 +410,7 @@ export function parseInlineScriptMetadata(scriptText: string, source?: string): end += 1; } - return { + const result: InlineScriptMetadataParseResult = { kind: 'parsed', problems, metadata: { @@ -379,6 +421,13 @@ export function parseInlineScriptMetadata(scriptText: string, source?: string): sourceRange: toSourceRange(matchStart, end), }, }; + return { + ...result, + metadata: { + ...result.metadata, + sourceHash: getInlineScriptSetupProblem(result) === undefined ? hashScriptBlock(match[0]) : undefined, + }, + }; } /** 1-based line number of `offset` within LF-normalized `text`. */ @@ -447,13 +496,16 @@ function diagnoseMalformedBlock( opener: ScriptOpener, toSourceRange: (start: number, end: number) => { start: number; end: number }, where: string, + logProblems: boolean, ): MalformedBlockDiagnosis { const openerRange = toSourceRange(opener.offset, opener.lineEnd); if (opener.trailing.length > 0) { - traceWarn( - `inline script metadata${where}: the \`# /// script\` marker on line ${countLines(text, opener.offset)} has trailing whitespace`, - ); + if (logProblems) { + traceWarn( + `inline script metadata${where}: the \`# /// script\` marker on line ${countLines(text, opener.offset)} has trailing whitespace`, + ); + } return { ignorableBelowHeader: false, problem: { @@ -475,9 +527,11 @@ function diagnoseMalformedBlock( } if (line !== line.trimEnd() && line.trimEnd() === CLOSER_LINE) { - traceWarn( - `inline script metadata${where}: the closing \`# ///\` marker on line ${countLines(text, offset)} has trailing whitespace`, - ); + if (logProblems) { + traceWarn( + `inline script metadata${where}: the closing \`# ///\` marker on line ${countLines(text, offset)} has trailing whitespace`, + ); + } return { ignorableBelowHeader: false, problem: { @@ -494,10 +548,12 @@ function diagnoseMalformedBlock( // closing marker is still ahead the author wrote a real block around a bad line. const isComment = line.startsWith('#'); if (isComment || hasCloserAhead(text, lineEnd)) { - traceWarn( - `inline script metadata${where}: invalid content line ${countLines(text, offset)} ` + - `(expected '#' or '# '): ${JSON.stringify(line)}`, - ); + if (logProblems) { + traceWarn( + `inline script metadata${where}: invalid content line ${countLines(text, offset)} ` + + `(expected '#' or '# '): ${JSON.stringify(line)}`, + ); + } return { ignorableBelowHeader: !isComment, problem: { @@ -520,9 +576,11 @@ function diagnoseMalformedBlock( offset = lineEnd + 1; } - traceWarn( - `inline script metadata${where}: the \`# /// script\` block on line ${countLines(text, opener.offset)} is missing its closing \`# ///\` marker`, - ); + if (logProblems) { + traceWarn( + `inline script metadata${where}: the \`# /// script\` block on line ${countLines(text, opener.offset)} is missing its closing \`# ///\` marker`, + ); + } return { ignorableBelowHeader: true, problem: { code: 'unterminated-block', severity: 'warning', sourceRange: openerRange }, @@ -654,8 +712,35 @@ export function sliceHeaderBytes(text: string): string { * - any I/O error (logged at `traceVerbose`); * - any of the malformed-metadata cases handled by * `readInlineScriptMetadata`. + * @param uri The local script to read. + * @param strict Throw on I/O errors or invalid metadata instead of treating them as absent. */ -export async function readInlineScriptMetadataFromFile(uri: Uri): Promise { +export async function readInlineScriptMetadataFromFile( + uri: Uri, + strict = false, +): Promise { + const text = await readInlineScriptHeaderFromFile(uri, strict); + if (text === undefined) { + return undefined; + } + const result = parseInlineScriptMetadata(text, uri.fsPath); + if ( + strict && + (result.kind === 'invalid' || + (result.kind === 'parsed' && result.problems.some((problem) => problem.severity === 'error'))) + ) { + throw new Error(l10n.t('Fix the PEP 723 metadata in {0} before configuring its environment.', uri.fsPath)); + } + return result.kind === 'parsed' ? result.metadata : undefined; +} + +/** + * Read at most MAX_HEADER_BYTES of a local script as UTF-8, without parsing its metadata. + * Unsupported URI schemes and I/O failures return undefined and are logged. + * @param uri The local script to read. + * @param strict Throw on I/O errors instead of treating them as absent. + */ +export async function readInlineScriptHeaderFromFile(uri: Uri, strict = false): Promise { if (uri.scheme !== 'file') { traceVerbose(`inline script metadata: skipping non-file URI scheme '${uri.scheme}'`); return undefined; @@ -671,11 +756,14 @@ export async function readInlineScriptMetadataFromFile(uri: Uri): Promise(); private readonly _onDidChangeMetadata = new EventEmitter(); private readonly _onDidChangeAvailability = new EventEmitter(); + private readonly _onDidChangeEnvironmentVersion = new EventEmitter(); public readonly onDidChangeAvailability: Event = this._onDidChangeAvailability.event; + public readonly onDidChangeEnvironmentVersion: Event = this._onDidChangeEnvironmentVersion.event; public readonly onDidChangeRouteability: Event = this._onDidChangeRouteability.event; @@ -75,6 +79,7 @@ export class InlineScriptRoutingRegistry implements Disposable { metadata, metadataIdentity, metadataRevision, + liveMetadataMatchesSaved: metadata ? true : undefined, validatedAssociation: state.metadataIdentity === metadataIdentity ? state.validatedAssociation : false, environmentUnavailable: @@ -100,6 +105,7 @@ export class InlineScriptRoutingRegistry implements Disposable { metadata: undefined, metadataIdentity: undefined, metadataRevision, + liveMetadataMatchesSaved: undefined, }; }, true, @@ -126,7 +132,18 @@ export class InlineScriptRoutingRegistry implements Disposable { return scriptPath ? this.states.get(scriptPath)?.uri : undefined; } - public setValidatedAssociation(script: Uri | string, validatedAssociation: boolean): void { + /** The Python version of the validated script environment, when one is known. */ + public getEnvironmentVersion(script: Uri | string): string | undefined { + const scriptPath = getInlineScriptRoutingKey(script); + const state = scriptPath ? this.states.get(scriptPath) : undefined; + return this.isRouteable(state) ? state?.environmentVersion : undefined; + } + + public setValidatedAssociation( + script: Uri | string, + validatedAssociation: boolean, + environmentVersion?: string, + ): void { const scriptPath = getInlineScriptRoutingKey(script); if (!scriptPath) { return; @@ -135,6 +152,7 @@ export class InlineScriptRoutingRegistry implements Disposable { ...state, uri: script instanceof Uri ? script : state.uri, validatedAssociation, + environmentVersion: validatedAssociation ? environmentVersion ?? state.environmentVersion : undefined, environmentUnavailable: validatedAssociation ? state.environmentUnavailable : false, })); } @@ -159,6 +177,18 @@ export class InlineScriptRoutingRegistry implements Disposable { return scriptPath ? this.states.get(scriptPath)?.validatedAssociation === true : false; } + /** + * Temporarily suppress routing while an open document's live metadata block differs from the + * saved block. The validated association is retained so Undo can restore routing immediately. + */ + public setLiveMetadataMatchesSaved(uri: Uri, matches: boolean): void { + const scriptPath = getInlineScriptRoutingKey(uri); + if (!scriptPath || !this.states.get(scriptPath)?.metadata) { + return; + } + this.update(scriptPath, (state) => ({ ...state, uri, liveMetadataMatchesSaved: matches })); + } + public noteSetupOutcome(script: Uri | string, outcome: InlineScriptSetupOutcome): void { const scriptPath = getInlineScriptRoutingKey(script); if (scriptPath) { @@ -200,6 +230,7 @@ export class InlineScriptRoutingRegistry implements Disposable { this._onDidChangeMetadata.dispose(); this._onDidChangeRouteability.dispose(); this._onDidChangeAvailability.dispose(); + this._onDidChangeEnvironmentVersion.dispose(); } private update( @@ -241,10 +272,17 @@ export class InlineScriptRoutingRegistry implements Disposable { if (previouslyUnavailable !== (routeable && next.environmentUnavailable === true) && next.uri) { this._onDidChangeAvailability.fire(next.uri); } + if ( + (previousRouteable ? previous.environmentVersion : undefined) !== + (routeable ? next.environmentVersion : undefined) && + next.uri + ) { + this._onDidChangeEnvironmentVersion.fire(next.uri); + } } private isRouteable(state: ScriptRoutingState | undefined): boolean { - return !!state?.metadata && state.validatedAssociation; + return !!state?.metadata && state.validatedAssociation && state.liveMetadataMatchesSaved !== false; } private nextMetadataRevision(scriptPath: string): number { diff --git a/src/common/localize.ts b/src/common/localize.ts index 3982a86fb..b9dd0bd9b 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -31,6 +31,15 @@ export namespace InlineScriptStrings { export const saveFailedBeforeSetup = l10n.t( 'Could not save this script, so its environment was not set up. Save the file and try again.', ); + export const invalidMetadataBeforeSetup = l10n.t( + "Fix the '# /// script' metadata before setting up this script's environment. Check the block markers, TOML, and declared dependencies.", + ); + export const invalidPythonRequirement = l10n.t( + "The script's 'requires-python' must be a valid Python version specifier, for example '>=3.11'.", + ); + export const scriptReadFailedBeforeSetup = l10n.t( + 'Could not read this script, so its environment was not set up. Make sure the file is available and try again.', + ); export const updatePythonExtension = l10n.t( 'The environment for this script was created. Update the Python extension for the full inline script experience.', @@ -382,6 +391,10 @@ export namespace UvInstallStrings { ); export const clickToInstallPython = l10n.t('No Python found, click to install'); export const selectPythonVersion = l10n.t('Select Python version to install'); + export const installUvForVersionLookupPrompt = l10n.t( + 'The Global environment manager uses uv to find and install Python versions. uv was not found. Install uv to continue, or install Python another way.', + ); + export const otherPythonInstallationOptions = l10n.t('Other installation options'); export const installed = l10n.t('installed'); export const fetchingVersions = l10n.t('Fetching available Python versions...'); export const failedToFetchVersions = l10n.t('Failed to fetch available Python versions'); diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 4fbcc0432..65d192f6b 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -8,6 +8,7 @@ import * as path from 'path'; export interface AcquireFileLockOptions { readonly timeoutMs: number; readonly retryIntervalMs: number; + readonly checkCancellation?: () => void; } export interface AcquiredFileLock { @@ -52,6 +53,7 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock const deadline = Date.now() + options.timeoutMs; while (true) { + options.checkCancellation?.(); try { await fsapi.mkdir(lockPath); try { diff --git a/src/common/pickers/environments.ts b/src/common/pickers/environments.ts index 5f205c421..86a9ba059 100644 --- a/src/common/pickers/environments.ts +++ b/src/common/pickers/environments.ts @@ -9,6 +9,7 @@ import { isWindows } from '../utils/platformUtils'; import { handlePythonPath } from '../utils/pythonPath'; import { showErrorMessage, + showInputBox, showOpenDialog, showQuickPick, showQuickPickWithButtons, @@ -61,8 +62,29 @@ async function browseForPython( if (!uris || uris.length === 0) { return; } - const uri = uris[0]; + return resolvePython(uris[0], managers, projectEnvManagers); +} +async function enterPythonPath( + managers: InternalEnvironmentManager[], + projectEnvManagers: InternalEnvironmentManager[], +): Promise { + const interpreterPath = await showInputBox({ + title: Interpreter.enterInterpreterPath, + prompt: l10n.t('Enter the full path to a Python interpreter'), + ignoreFocusOut: true, + }); + if (!interpreterPath?.trim()) { + return; + } + return resolvePython(Uri.file(interpreterPath.trim()), managers, projectEnvManagers); +} + +async function resolvePython( + uri: Uri, + managers: InternalEnvironmentManager[], + projectEnvManagers: InternalEnvironmentManager[], +): Promise { const environment = await withProgress( { location: ProgressLocation.Notification, @@ -136,6 +158,8 @@ async function pickEnvironmentImpl( if (selected && !Array.isArray(selected)) { if (selected.label === Interpreter.browsePath) { return browseForPython(managers, projectEnvManagers); + } else if (selected.label === Interpreter.enterInterpreterPath) { + return enterPythonPath(managers, projectEnvManagers); } else if (selected.label === Interpreter.createVirtualEnvironment) { sendTelemetryEvent(EventNames.CREATE_ENVIRONMENT, undefined, { manager: 'none', @@ -154,6 +178,10 @@ export async function pickEnvironment( options: EnvironmentPickOptions, ): Promise { const items: (QuickPickItem | (QuickPickItem & { result: PythonEnvironment }))[] = [ + { + label: Interpreter.enterInterpreterPath, + iconPath: new ThemeIcon('edit'), + }, { label: Interpreter.browsePath, iconPath: new ThemeIcon('folder'), diff --git a/src/common/utils/processRunner.ts b/src/common/utils/processRunner.ts new file mode 100644 index 000000000..66082c93a --- /dev/null +++ b/src/common/utils/processRunner.ts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import type { ChildProcess, SpawnOptions } from 'child_process'; +import { CancellationError, CancellationToken, Disposable, l10n, LogOutputChannel } from 'vscode'; +import { spawnProcess } from '../childProcess.apis'; +import { traceError } from '../logging'; +import { isWindows } from './platformUtils'; + +export const PROCESS_TERMINATION_TIMEOUT_MS = 5_000; + +export class ProcessTimeoutError extends Error { + constructor(command: string, timeoutMs: number) { + super(l10n.t('Timed out after {0}ms running {1}.', timeoutMs, command)); + this.name = 'ProcessTimeoutError'; + } +} + +export class ProcessTerminationError extends Error { + constructor(executable: string, public readonly operationError: Error, public readonly cause: unknown) { + super( + l10n.t( + '{0} Unable to stop {1}; it may still be modifying the environment. {2}', + operationError.message, + executable, + cause instanceof Error ? cause.message : String(cause), + ), + ); + this.name = 'ProcessTerminationError'; + } +} + +function isMissingProcess(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ESRCH'; +} + +async function terminateOwnedProcess(proc: ChildProcess, processGroup: boolean, closed: Promise): Promise { + let expired = false; + let timer: ReturnType | undefined; + const terminate = async () => { + let descendantsUnconfirmed = false; + if (processGroup && proc.pid) { + try { + process.kill(-proc.pid, 'SIGKILL'); + } catch (error) { + if (!isMissingProcess(error)) { + throw error; + } + } + } else if (proc.exitCode === null && proc.signalCode === null) { + if (isWindows() && proc.pid) { + await new Promise((resolve, reject) => { + const killer = spawnProcess('taskkill.exe', ['/PID', String(proc.pid), '/T', '/F'], { + windowsHide: true, + timeout: PROCESS_TERMINATION_TIMEOUT_MS, + }); + killer.on('error', reject); + killer.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Unable to terminate process tree ${proc.pid} (taskkill exit ${code}).`)); + } + }); + }); + } else if (!proc.kill() && proc.exitCode === null && proc.signalCode === null) { + throw new Error('The process did not accept the termination signal.'); + } + } else if (isWindows() && proc.pid) { + descendantsUnconfirmed = true; + } + // Parent exit does not prove descendant cleanup; never signal a reused Windows PID. + await closed; + if (descendantsUnconfirmed) { + throw new Error( + l10n.t('The parent process exited before cancellation; child-process cleanup cannot be confirmed.'), + ); + } + if (processGroup && proc.pid) { + while (!expired) { + try { + process.kill(-proc.pid, 0); + } catch (error) { + if (isMissingProcess(error)) { + return; + } + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + }; + try { + await Promise.race([ + terminate(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + expired = true; + reject( + new Error( + l10n.t('Process cleanup did not complete within {0}ms.', PROCESS_TERMINATION_TIMEOUT_MS), + ), + ); + }, PROCESS_TERMINATION_TIMEOUT_MS); + }), + ]); + } finally { + expired = true; + clearTimeout(timer); + } +} + +/** Runs a subprocess and awaits owned-process cleanup on cancellation or timeout. */ +export async function runLoggedProcess( + executable: string, + args: string[], + options: SpawnOptions, + log?: LogOutputChannel, + token?: CancellationToken, + timeoutMs?: number, + includeStderr = false, +): Promise { + if (token?.isCancellationRequested) { + throw new CancellationError(); + } + const processGroup = !isWindows() && (!!token || timeoutMs !== undefined); + const spawnOptions = { ...options }; + if (processGroup) { + spawnOptions.detached = true; + } + if (token) { + spawnOptions.env = { ...options.env, PIP_NO_INPUT: '1', UV_NO_PROGRESS: '1' }; + } + log?.info(`Running: ${executable} ${args.join(' ')}`); + const proc = spawnProcess(executable, args, spawnOptions); + proc.stdin?.end(); + return new Promise((resolve, reject) => { + let output = ''; + let stderr = ''; + let settled = false; + let abortError: Error | undefined; + let exitCode: number | null | undefined; + let subscription: Disposable | undefined; + let timer: ReturnType | undefined; + let resolveClosed: (() => void) | undefined; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + const finish = (error?: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + subscription?.dispose(); + if (error) { + reject(error); + } else { + resolve(output); + } + }; + const abort = (error: Error) => { + if (settled || abortError) { + return; + } + abortError = error; + void terminateOwnedProcess(proc, processGroup, closed).then( + () => finish(error), + (killError) => { + traceError(`Failed to terminate ${executable}:`, killError); + finish(new ProcessTerminationError(executable, error, killError)); + }, + ); + }; + proc.stdout?.on('data', (data) => { + if (settled) { + return; + } + const text = data.toString('utf-8'); + output += text; + log?.append(text); + }); + proc.stderr?.on('data', (data) => { + if (settled) { + return; + } + const text = data.toString('utf-8'); + stderr += text; + if (includeStderr) { + output += text; + } + log?.append(text); + }); + proc.on('error', (error) => { + if (!abortError) { + if (proc.pid) { + abort(error); + } else { + finish(error); + } + } + }); + proc.on('exit', (code) => { + exitCode = code; + }); + proc.on('close', (code, signal) => { + resolveClosed?.(); + if (!abortError) { + finish( + (code ?? exitCode) === 0 + ? undefined + : new Error(`Failed to run ${executable} (${signal ?? code ?? exitCode}): ${stderr.trim()}`), + ); + } + }); + subscription = token?.onCancellationRequested(() => abort(new CancellationError())); + if (timeoutMs !== undefined) { + timer = setTimeout(() => abort(new ProcessTimeoutError(executable, timeoutMs)), timeoutMs); + } + if (token?.isCancellationRequested) { + abort(new CancellationError()); + } + }); +} diff --git a/src/common/utils/pythonPath.ts b/src/common/utils/pythonPath.ts index 59afba8fe..bd5093c3e 100644 --- a/src/common/utils/pythonPath.ts +++ b/src/common/utils/pythonPath.ts @@ -40,7 +40,19 @@ export async function handlePythonPath( projectEnvManagers: InternalEnvironmentManager[], reporter?: Progress<{ message?: string; increment?: number }>, token?: CancellationToken, + toolExecution = false, ): Promise { + const resolve = (manager: InternalEnvironmentManager) => { + if (!toolExecution) { + return manager.resolve(interpreterUri); + } + const tools = manager.tools; + if (!tools) { + traceVerbose(`Skipping ${manager.id}: no non-interactive interpreter resolver.`); + return undefined; + } + return tools.resolve(interpreterUri); + }; // Use the managers user has set for the project first. Likely, these // managers are the ones that should be used. for (const manager of sortManagersByPriority(projectEnvManagers)) { @@ -49,7 +61,7 @@ export async function handlePythonPath( } reporter?.report({ message: `Checking ${manager.displayName}` }); traceVerbose(`Checking ${manager.displayName} (${manager.id}) for ${interpreterUri.fsPath}`); - const env = await manager.resolve(interpreterUri); + const env = await resolve(manager); if (env) { traceVerbose(`Using ${manager.displayName} (${manager.id}) to handle ${interpreterUri.fsPath}`); return env; @@ -68,7 +80,7 @@ export async function handlePythonPath( } reporter?.report({ message: `Checking ${manager.displayName}` }); traceVerbose(`Checking ${manager.displayName} (${manager.id}) for ${interpreterUri.fsPath}`); - const env = await manager.resolve(interpreterUri); + const env = await resolve(manager); if (env) { traceVerbose(`Using ${manager.displayName} (${manager.id}) to handle ${interpreterUri.fsPath}`); return env; diff --git a/src/common/workspace.apis.ts b/src/common/workspace.apis.ts index 06f5257e0..92acd071b 100644 --- a/src/common/workspace.apis.ts +++ b/src/common/workspace.apis.ts @@ -24,6 +24,7 @@ export function getWorkspaceFolders(): readonly WorkspaceFolder[] | undefined { return workspace.workspaceFolders; } +/** Returns whether the workspace is trusted. */ export function isWorkspaceTrusted(): boolean { return workspace.isTrusted; } diff --git a/src/extension.ts b/src/extension.ts index 785ecea3d..21c0755ac 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,7 +36,6 @@ import { onDidChangeTerminalShellIntegration, withProgress, } from './common/window.apis'; -import { getConfiguration, getWorkspaceFolders } from './common/workspace.apis'; import { createManagerReady } from './features/common/managerReady'; import { AutoFindProjects } from './features/creators/autoFindProjects'; import { ExistingProjects } from './features/creators/existingProjects'; @@ -80,7 +79,10 @@ import { PythonProjectManagerImpl } from './features/projectManager'; import { reportIssue } from './features/reportIssue'; import { getPythonApi, setPythonApi } from './extensionApi'; import { registerCompletionProvider } from './features/settings/settingCompletions'; -import { migrateGlobalDefaultEnvManagerSetting } from './features/settings/settingHelpers'; +import { + isEnvironmentsExtensionEnabled, + migrateGlobalDefaultEnvManagerSetting, +} from './features/settings/settingHelpers'; import { setActivateMenuButtonContext } from './features/terminal/activateMenuButton'; import { normalizeShellPath } from './features/terminal/shells/common/shellUtils'; import { createShellEnvProviders, createShellStartupProviders } from './features/terminal/shells/providers'; @@ -124,29 +126,7 @@ import { registerPyenvFeatures } from './managers/pyenv/main'; export async function activate(context: ExtensionContext): Promise { // Only skip activation if user explicitly set useEnvironmentsExtension to false. // When disabled, the main Python extension handles environments instead (legacy mode). - const config = getConfiguration('python'); - const inspection = config.inspect('useEnvironmentsExtension'); - - // Check global and workspace-level explicit disables - let explicitlyDisabled = inspection?.globalValue === false || inspection?.workspaceValue === false; - - // Also check folder-scoped settings in multi-root workspaces - // (inspect() on an unscoped config won't populate workspaceFolderValue reliably) - if (!explicitlyDisabled) { - const workspaceFolders = getWorkspaceFolders(); - if (workspaceFolders) { - for (const folder of workspaceFolders) { - const folderConfig = getConfiguration('python', folder.uri); - const folderInspection = folderConfig.inspect('useEnvironmentsExtension'); - if (folderInspection?.workspaceFolderValue === false) { - explicitlyDisabled = true; - break; - } - } - } - } - - const useEnvironmentsExtension = !explicitlyDisabled; + const useEnvironmentsExtension = isEnvironmentsExtensionEnabled(); traceInfo(`Experiment Status: useEnvironmentsExtension setting set to ${useEnvironmentsExtension}`); if (!useEnvironmentsExtension) { traceWarn( diff --git a/src/extensionApi.ts b/src/extensionApi.ts index 337322ed1..c916f2f4f 100644 --- a/src/extensionApi.ts +++ b/src/extensionApi.ts @@ -53,6 +53,8 @@ import { runAsTask } from './features/execution/runAsTask'; import { runInBackground } from './features/execution/runInBackground'; import { runInTerminal } from './features/terminal/runInTerminal'; import { TerminalManager } from './features/terminal/terminalManager'; +import { PythonToolsApiImpl } from './internal/pythonTools'; +import type { PythonToolsApi } from './internal/pythonToolsApi'; // Maximum time getEnvironment will block before serving the last-known environment while a // slow initial resolution/refresh continues in the background. Keeps consumers (e.g. Pylance's @@ -61,6 +63,7 @@ const GET_ENVIRONMENT_TIMEOUT_MS = 1000; const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut'); export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { + readonly __pythonTools: PythonToolsApi; private readonly _onDidChangeEnvironments = new EventEmitter(); private readonly _onDidChangeEnvironment = new EventEmitter(); private readonly _onDidChangePythonProjects = new EventEmitter(); @@ -78,6 +81,7 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly envVarManager: EnvVarManager, private readonly disposables: Disposable[] = [], ) { + this.__pythonTools = new PythonToolsApiImpl(envManagers, projectManager); this.previousProjects = this.projectManager.getProjects(); this.disposables.push( diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index ece0a6bc3..a1253f0f8 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { ConfigurationTarget, Disposable, Event, EventEmitter, Uri, workspace } from 'vscode'; +import { CancellationToken, ConfigurationTarget, Disposable, Event, EventEmitter, l10n, Uri, workspace } from 'vscode'; import type { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -31,6 +31,7 @@ import { getCallingExtension } from '../common/utils/frameUtils'; import { normalizePath } from '../common/utils/pathUtils'; import { InternalEnvironmentManager, InternalPackageManager } from '../managers/common/registeredManagers'; import { ProjectScopedPackageManagerCache } from '../managers/common/projectScopedPackageManagerCache'; +import { PythonToolError, throwIfCancelled } from '../internal/pythonToolSupport'; import type { PythonProjectManager, PythonProjectSettings } from './projectManager'; import { EditAllManagerSettings, @@ -139,6 +140,7 @@ export interface EnvironmentManagers extends Disposable { environment?: PythonEnvironment, shouldPersistSettings?: boolean, ): Promise; + setEnvironmentForTools(scope: Uri, environment: PythonEnvironment, token: CancellationToken): Promise; /** * Sets environments for multiple scopes. * @param scope - Array of URIs or 'global' @@ -564,14 +566,35 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { return this.setEnvironmentCore(scope, environment, shouldPersistSettings); } + /** + * Selects an environment without interactive prompts and persists the exact project target. + * @param scope The project or script targeted by the tool. + * @param environment The resolved environment to select. + * @param token Cancellation for the tool-owned selection operation. + */ + public setEnvironmentForTools(scope: Uri, environment: PythonEnvironment, token: CancellationToken): Promise { + const run = () => this.setEnvironmentCore(scope, environment, true, token); + return this.shouldSerializeInlineScriptProjectSelection(scope, environment, true) + ? this.enqueueInlineScriptProjectSelection(run) + : run(); + } + private async setEnvironmentCore( scope: Uri | undefined, environment: PythonEnvironment | undefined, shouldPersistSettings: boolean, + token?: CancellationToken, ): Promise { + throwIfCancelled(token); const customScope = environment ? environment : scope; const manager = this.getEnvironmentManager(customScope); if (!manager) { + if (token) { + throw new PythonToolError( + 'MANAGER_UNAVAILABLE', + l10n.t('The environment manager is no longer registered.'), + ); + } traceError( `No environment manager found for scope: ${ customScope instanceof Uri ? customScope.fsPath : customScope?.environmentPath?.fsPath @@ -581,6 +604,12 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { traceError(this.managers.map((m) => m.id).join(', ')); return; } + if (token && (!scope || !environment || !manager.tools)) { + throw new PythonToolError( + 'UNSUPPORTED_MANAGER', + l10n.t('Non-interactive selection is not supported by {0}.', manager.id), + ); + } const inlineRegistration = scope && environment && manager.id === INLINE_SCRIPT_MANAGER_ID && shouldPersistSettings ? await this.prepareInlineScriptProjectRegistration(scope) @@ -605,7 +634,13 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { ? this.beginSelectionOperation(this.getInlineScriptSelectionKey(scope)) : undefined; try { - await manager.set(scope, environment); + throwIfCancelled(token); + if (token && scope && environment && manager.tools?.set) { + await manager.tools.set(scope, environment, token); + } else { + await manager.set(scope, environment); + } + throwIfCancelled(token); } catch (error) { if (inlineRegistration) { await this.rollbackInlineScriptProjectRegistration(inlineRegistration); @@ -622,19 +657,23 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { // Only persist to settings when explicitly requested if (shouldPersistSettings && scope) { + throwIfCancelled(token); const packageManager = this.getPackageManager(environment); const canPersistSettings = project && packageManager && this.canPersistManagerSettingForScope(scope, manager, project); if (canPersistSettings) { - await setAllManagerSettings([ - { - project, - envManager: manager.id, - packageManager: packageManager.id, - }, - ]); + await setAllManagerSettings( + [ + { + project, + envManager: manager.id, + packageManager: packageManager.id, + }, + ], + !!token, + ); } traceVerbose( `[setEnvironment] scope=${scope instanceof Uri ? scope.fsPath : scope}, ` + @@ -1210,7 +1249,11 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { } private shouldPublishInlineSelectionImmediately(scope: Uri, manager: InternalEnvironmentManager): boolean { - return !this.inlineScriptRouting || manager.id !== INLINE_SCRIPT_MANAGER_ID || this.inlineScriptRouting.shouldRoute(scope); + return ( + !this.inlineScriptRouting || + manager.id !== INLINE_SCRIPT_MANAGER_ID || + this.inlineScriptRouting.shouldRoute(scope) + ); } private clearInlineActiveSelection( @@ -1242,7 +1285,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { const operation = manager.id === INLINE_SCRIPT_MANAGER_ID ? selectionOperation - : (inlineOverrideHandoffOperation ?? inlineClearOperation); + : inlineOverrideHandoffOperation ?? inlineClearOperation; return ( operation === undefined || this.commitSelectionOperation(this.getInlineScriptSelectionKey(scope), operation) diff --git a/src/features/execution/envVariableManager.ts b/src/features/execution/envVariableManager.ts index d811c7ec0..00f8bab45 100644 --- a/src/features/execution/envVariableManager.ts +++ b/src/features/execution/envVariableManager.ts @@ -8,6 +8,23 @@ import { createFileSystemWatcher, getConfiguration } from '../../common/workspac import type { PythonProjectManager } from '../projectManager'; import { mergeEnvVariables, parseEnvFile } from './envVarUtils'; +/** + * Returns true if the path resolves (following symlinks) to a regular file. + * Missing paths and paths with a non-directory parent are treated as absent; + * other errors (for example, permission errors) are propagated. + */ +async function isEnvFile(filePath: string): Promise { + try { + return (await fsapi.stat(filePath)).isFile(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return false; + } + throw error; + } +} + export interface EnvVarManager extends PythonEnvironmentVariablesApi, Disposable {} export class PythonEnvVariableManager implements EnvVarManager { @@ -50,7 +67,7 @@ export class PythonEnvVariableManager implements EnvVarManager { let envFilePath = config.get('envFile'); envFilePath = envFilePath ? path.normalize(resolveVariables(envFilePath, uri)) : undefined; - if (envFilePath && (await fsapi.pathExists(envFilePath))) { + if (envFilePath && (await isEnvFile(envFilePath))) { const other = await parseEnvFile(Uri.file(envFilePath)); env = mergeEnvVariables(env, other); } @@ -59,7 +76,7 @@ export class PythonEnvVariableManager implements EnvVarManager { if ( projectEnvFilePath && projectEnvFilePath?.toLowerCase() !== envFilePath?.toLowerCase() && - (await fsapi.pathExists(projectEnvFilePath)) + (await isEnvFile(projectEnvFilePath)) ) { const other = await parseEnvFile(Uri.file(projectEnvFilePath)); env = mergeEnvVariables(env, other); diff --git a/src/features/inlineScript/codeLens.ts b/src/features/inlineScript/codeLens.ts index 460c4b8dc..0020b7878 100644 --- a/src/features/inlineScript/codeLens.ts +++ b/src/features/inlineScript/codeLens.ts @@ -11,45 +11,36 @@ import { languages, Range, TextDocument, - Uri, + TextDocumentChangeEvent, } from 'vscode'; +import { findInlineScriptBlock } from '../../common/inlineScript/block'; +import { getInlineScriptSourceHash, sliceHeaderBytes } from '../../common/inlineScript/metadata'; import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { InlineScriptStrings } from '../../common/localize'; - -export const READY_CONFIRMATION_TIMEOUT_MS = 5000; +import { onDidChangeTextDocument } from '../../common/workspace.apis'; +import { shortenVersionString } from '../../managers/common/utils'; /** - * Shows a single "Set up environment for this script" CodeLens above a `.py` file's PEP 723 - * `# /// script` block, but only when the file has saved inline metadata that is not currently - * backed by a validated inline-script environment. - * - * The provider is a pure observer of {@link InlineScriptRoutingRegistry}: - * - `getMetadata` returns the last saved metadata (the detector clears it while the metadata - * region is dirty), so the lens tracks *saved* metadata and disappears while it is being edited. - * - `shouldRoute` is true once a validated association matching the current metadata exists, so the - * lens hides after setup and reappears if a metadata change later invalidates that association. + * Keep a CodeLens above the live inline block, including malformed or unsaved metadata. + * Only an unchanged block backed by a validated environment displays the persistent ready label. + * Text detection and hashing do not parse TOML or change saved-metadata routing. */ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposable { private readonly _onDidChangeCodeLenses = new EventEmitter(); public readonly onDidChangeCodeLenses = this._onDidChangeCodeLenses.event; private readonly subscriptions: Disposable[] = []; - private readonly readyConfirmations = new Map< - string, - { readonly version: string | undefined; readonly timer: ReturnType } - >(); + private readonly liveBlockKeys = new Set(); private disposed = false; constructor( private readonly routing: InlineScriptRoutingRegistry, private readonly setupCommand: string, - private readonly confirmationTimeoutMs: number = READY_CONFIRMATION_TIMEOUT_MS, ) { this.subscriptions.push( + onDidChangeTextDocument((event) => this.handleDocumentChange(event)), this.routing.onDidChangeRouteability(() => this._onDidChangeCodeLenses.fire()), this.routing.onDidChangeAvailability(() => this._onDidChangeCodeLenses.fire()), - // Only metadata arriving or changing can add/replace a lens; a scan that finds no metadata - // (the common case for ordinary .py files) needs no refresh. Hiding a lens for an - // edited/removed block is handled by VS Code re-querying on the document change itself. + this.routing.onDidChangeEnvironmentVersion(() => this._onDidChangeCodeLenses.fire()), this.routing.onDidChangeMetadata((e) => { if (e.metadata !== undefined) { this._onDidChangeCodeLenses.fire(); @@ -58,49 +49,33 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl ); } - public noteEnvironmentReady(uri: Uri, version: string | undefined): void { - const key = getInlineScriptRoutingKey(uri); - if (this.disposed || !key) { - return; - } - this.clearConfirmation(key); - this.readyConfirmations.set(key, { - version, - timer: setTimeout(() => { - this.readyConfirmations.delete(key); - this._onDidChangeCodeLenses.fire(); - }, this.confirmationTimeoutMs), - }); - this._onDidChangeCodeLenses.fire(); - } - public provideCodeLenses(document: TextDocument, _token: CancellationToken): CodeLens[] { - if (document.isDirty) { - // The association is validated against the saved file (the manager refuses to validate a - // dirty document), so only offer setup for a clean document. This also avoids anchoring the - // lens at a stale offset if the block moved on an unsaved edit. + const uri = document.uri; + const routingKey = getInlineScriptRoutingKey(uri); + if (this.disposed || !routingKey) { return []; } - const uri = document.uri; - const metadata = this.routing.getMetadata(uri); - if (!metadata) { - // No saved PEP 723 metadata (or it is currently being edited). + const header = sliceHeaderBytes(document.getText()); + const block = findInlineScriptBlock(header); + if (!block) { + this.liveBlockKeys.delete(routingKey); return []; } - const offset = metadata.sourceRange?.start ?? metadata.range.start; - const position = document.positionAt(offset); + this.liveBlockKeys.add(routingKey); + const position = document.positionAt(block.start); const range = new Range(position, position); - if (this.routing.shouldRoute(uri) && !this.routing.isEnvironmentUnavailable(uri)) { - // A validated inline-script environment matching the current metadata already exists. - const key = getInlineScriptRoutingKey(uri); - const confirmation = key ? this.readyConfirmations.get(key) : undefined; - if (!confirmation) { - return []; - } + const savedHash = this.routing.getMetadata(uri)?.sourceHash; + if ( + this.routing.shouldRoute(uri) && + !this.routing.isEnvironmentUnavailable(uri) && + savedHash !== undefined && + savedHash === getInlineScriptSourceHash(header) + ) { + const version = this.routing.getEnvironmentVersion(uri); // An empty command id renders the title as plain, non-clickable text. return [ new CodeLens(range, { - title: InlineScriptStrings.environmentReady(confirmation.version), + title: InlineScriptStrings.environmentReady(version ? shortenVersionString(version) : undefined), command: '', }), ]; @@ -114,22 +89,47 @@ export class InlineScriptCodeLensProvider implements CodeLensProvider, Disposabl ]; } + private handleDocumentChange(event: TextDocumentChangeEvent): void { + if (this.disposed || event.contentChanges.length === 0) { + return; + } + const uri = event.document.uri; + const routingKey = getInlineScriptRoutingKey(uri); + if (!routingKey) { + return; + } + + const header = sliceHeaderBytes(event.document.getText()); + const block = findInlineScriptBlock(header); + const hadLiveBlock = this.liveBlockKeys.has(routingKey); + if (block) { + this.liveBlockKeys.add(routingKey); + } else { + this.liveBlockKeys.delete(routingKey); + } + + if (hadLiveBlock !== !!block) { + this._onDidChangeCodeLenses.fire(); + return; + } + + const savedMetadata = this.routing.getMetadata(uri); + const blockEnd = block?.end ?? savedMetadata?.sourceRange?.end ?? savedMetadata?.range.end; + if ( + blockEnd !== undefined && + event.contentChanges.some((change) => change.rangeOffset < blockEnd) + ) { + this._onDidChangeCodeLenses.fire(); + } + } + public dispose(): void { this.disposed = true; this.subscriptions.forEach((s) => s.dispose()); this.subscriptions.length = 0; - this.readyConfirmations.forEach((confirmation) => clearTimeout(confirmation.timer)); - this.readyConfirmations.clear(); + this.liveBlockKeys.clear(); this._onDidChangeCodeLenses.dispose(); } - - private clearConfirmation(key: string): void { - const existing = this.readyConfirmations.get(key); - if (existing) { - clearTimeout(existing.timer); - this.readyConfirmations.delete(key); - } - } } /** diff --git a/src/features/inlineScript/lazyDetector.ts b/src/features/inlineScript/lazyDetector.ts index 5af895089..ed4bc0c40 100644 --- a/src/features/inlineScript/lazyDetector.ts +++ b/src/features/inlineScript/lazyDetector.ts @@ -3,7 +3,11 @@ import * as path from 'path'; import { Disposable, TextDocument, TextDocumentChangeEvent, TextDocumentContentChangeEvent, Uri } from 'vscode'; -import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata'; +import { + getInlineScriptSourceHash, + readInlineScriptMetadataFromFile, + sliceHeaderBytes, +} from '../../common/inlineScript/metadata'; import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { traceVerbose, traceWarn } from '../../common/logging'; import { EventNames } from '../../common/telemetry/constants'; @@ -278,7 +282,18 @@ export class InlineScriptLazyDetector implements Disposable { if (this.routingRegistry) { const key = this.getReadKey(e.document.uri); const metadata = this.routingRegistry.getMetadata(e.document.uri); - if ( + // Saved offsets can become stale when an unchanged block moves, so fingerprinted headers are compared directly. + if (metadata?.sourceHash) { + const liveMetadataMatchesSaved = + getInlineScriptSourceHash(sliceHeaderBytes(e.document.getText())) === metadata.sourceHash; + if (!liveMetadataMatchesSaved && this.inFlight.has(key)) { + this.advanceRoutingReadGeneration(key); + } + this.routingRegistry.setLiveMetadataMatchesSaved( + e.document.uri, + liveMetadataMatchesSaved, + ); + } else if ( (metadata && this.contentChangesMayAffectMetadata( e.contentChanges, diff --git a/src/features/inlineScript/setupCodeAction.ts b/src/features/inlineScript/setupCodeAction.ts index 769d7b0fd..695dd5f62 100644 --- a/src/features/inlineScript/setupCodeAction.ts +++ b/src/features/inlineScript/setupCodeAction.ts @@ -62,8 +62,8 @@ export function isUnresolvedImportDiagnostic(diagnostic: Diagnostic): boolean { * Offers "Set up this script's Python environment" as a quick fix on an unresolved import in a `.py` * file that declares a PEP 723 `# /// script` block and has no inline-script environment yet. * - * Complements the CodeLens, which is hidden while the document is dirty — the moment a user has just - * typed the import that does not resolve. This provider parses the in-memory buffer instead. + * Complements the inline-block CodeLens with an action at the unresolved import. Both use the live + * document, so the action remains available while the user is editing valid metadata. * * `diagnostics` and `isPreferred` are both left unset: setup installs the block's declared * dependencies verbatim and may not resolve the import at all, so the action must not claim to fix diff --git a/src/features/inlineScript/setupEnvironment.ts b/src/features/inlineScript/setupEnvironment.ts index 81ac711d8..d2b1db5d3 100644 --- a/src/features/inlineScript/setupEnvironment.ts +++ b/src/features/inlineScript/setupEnvironment.ts @@ -4,7 +4,13 @@ import { commands, Disposable, l10n, QuickPickItem, TextDocument, Uri, window } from 'vscode'; import { PythonEnvironment } from '../../api'; import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; -import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata'; +import { + getInlineScriptSetupProblem, + parseInlineScriptMetadata, + readInlineScriptHeaderFromFile, + readInlineScriptMetadataFromFile, + sliceHeaderBytes, +} from '../../common/inlineScript/metadata'; import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { InlineScriptStrings } from '../../common/localize'; import { traceError, traceInfo, traceVerbose } from '../../common/logging'; @@ -17,7 +23,6 @@ import { } from '../../common/window.apis'; import { asRelativePath, findFiles, getOpenTextDocuments } from '../../common/workspace.apis'; import type { EnvironmentManagers } from '../envManagers'; -import { shortenVersionString } from '../../managers/common/utils'; import { registerInlineScriptCodeLens } from './codeLens'; import { promptUpdateExtensionsForInlineScripts } from './extensionVersionCheck'; import { registerInlineScriptSetupCodeAction } from './setupCodeAction'; @@ -35,7 +40,7 @@ export const SETUP_INLINE_SCRIPT_ENV_COMMAND = 'python-envs.setupInlineScriptEnv */ export const SETUP_INLINE_SCRIPT_ENVS_COMMAND = 'python-envs.setupInlineScriptEnvs'; -/** Upper bound on the number of `.py` files the bulk command scans. */ +/** Upper bound on the number of Python files the bulk command scans. */ const MAX_INLINE_SCRIPT_FILES = 1000; /** How many candidate files' PEP 723 headers are read concurrently during the bulk scan. */ @@ -107,35 +112,63 @@ function findOpenDocument(scriptUri: Uri): TextDocument | undefined { } /** - * Save `scriptUri` if it is open with unsaved changes, so setup reads what the user actually sees. + * Save dirty scripts and seed saved metadata for open scripts, including clean Undo/Revert results. * * Returns `false` when the document could not be saved; setup must not run in that case. */ async function saveScriptBeforeSetup(scriptUri: Uri, routing: InlineScriptRoutingRegistry): Promise { const document = findOpenDocument(scriptUri); - if (!document?.isDirty) { + if (!document) { return true; } - if (!(await document.save())) { - traceError(`Could not save ${scriptUri.fsPath} before setting up its inline-script environment.`); - return false; + if (document.isDirty) { + if (!(await document.save())) { + traceError(`Could not save ${scriptUri.fsPath} before setting up its inline-script environment.`); + return false; + } + traceVerbose(`Saved ${scriptUri.fsPath} before setting up its inline-script environment.`); } - traceVerbose(`Saved ${scriptUri.fsPath} before setting up its inline-script environment.`); // Seeding here is load-bearing: without it a just-typed block goes from no metadata to an // identity while `create` runs, which `setUpInlineScriptEnvironment` reads as a concurrent edit // and silently skips the association. + const savedVersion = document.version; + const metadataRevision = routing.getMetadataRevision(scriptUri); const metadata = await readInlineScriptMetadataFromFile(scriptUri); - if (metadata) { + if ( + metadata && + !document.isDirty && + document.version === savedVersion && + routing.getMetadataRevision(scriptUri) === metadataRevision + ) { routing.setMetadata(scriptUri, metadata); } return true; } +async function validateScriptBeforeSetup(uri: Uri): Promise { + const document = findOpenDocument(uri); + const text = document ? sliceHeaderBytes(document.getText()) : await readInlineScriptHeaderFromFile(uri); + if (text === undefined) { + showErrorMessage(InlineScriptStrings.scriptReadFailedBeforeSetup); + return false; + } + const result = parseInlineScriptMetadata(text, uri.fsPath); + const problem = getInlineScriptSetupProblem(result); + if (problem === 'metadata') { + showWarningMessage(InlineScriptStrings.invalidMetadataBeforeSetup); + return false; + } + if (problem === 'requires-python') { + showWarningMessage(InlineScriptStrings.invalidPythonRequirement); + return false; + } + return true; +} + /** Handler for the single-file setup command, shared by the CodeLens and the quick fix. */ export function setupInlineScriptEnvironmentHandler( em: EnvironmentManagers, routing: InlineScriptRoutingRegistry, - onEnvironmentReady?: (scriptUri: Uri, version: string | undefined) => void, ): (scriptUri?: Uri) => Promise { return async (scriptUri?: Uri): Promise => { const uri = scriptUri ?? window.activeTextEditor?.document.uri; @@ -146,12 +179,18 @@ export function setupInlineScriptEnvironmentHandler( showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.')); return; } - if (!(await saveScriptBeforeSetup(uri, routing))) { - showErrorMessage(InlineScriptStrings.saveFailedBeforeSetup); - return; - } let environment: PythonEnvironment | undefined; try { + if (!(await validateScriptBeforeSetup(uri))) { + return; + } + if (!(await saveScriptBeforeSetup(uri, routing))) { + showErrorMessage(InlineScriptStrings.saveFailedBeforeSetup); + return; + } + if (!(await validateScriptBeforeSetup(uri))) { + return; + } environment = await setUpInlineScriptEnvironment(uri, em, routing); } catch (error) { traceError(`Failed to set up the inline-script environment for ${uri.fsPath}:`, error); @@ -166,7 +205,6 @@ export function setupInlineScriptEnvironmentHandler( notifyInlineScriptSetupOutcome(uri, routing); return; } - onEnvironmentReady?.(uri, shortenVersionString(environment.version)); // Kept out of the try: the environment is already set up, so a failure in this follow-up // must not be reported to the user as a setup failure. await promptUpdateExtensionsForInlineScripts().catch((error) => @@ -239,7 +277,11 @@ export async function setUpInlineScriptEnvironmentsInWorkspace( showErrorMessage(l10n.t('The inline script environment manager is not available yet. Try again shortly.')); return; } - const files = await findFiles('**/*.py', '{**/.venv/**,**/node_modules/**}', MAX_INLINE_SCRIPT_FILES); + const files = await findFiles( + '**/*.{py,Py,pY,PY}', + '{**/.venv/**,**/node_modules/**}', + MAX_INLINE_SCRIPT_FILES, + ); if (!files || files.length === 0) { showInformationMessage(l10n.t('No Python files were found in the workspace.')); return; @@ -371,9 +413,7 @@ export function registerInlineScriptUx(em: EnvironmentManagers, routing: InlineS registerInlineScriptSetupCodeAction(routing, SETUP_INLINE_SCRIPT_ENV_COMMAND), commands.registerCommand( SETUP_INLINE_SCRIPT_ENV_COMMAND, - setupInlineScriptEnvironmentHandler(em, routing, (uri, version) => - codeLens.provider.noteEnvironmentReady(uri, version), - ), + setupInlineScriptEnvironmentHandler(em, routing), ), commands.registerCommand(SETUP_INLINE_SCRIPT_ENVS_COMMAND, () => setUpInlineScriptEnvironmentsInWorkspace(em, routing), diff --git a/src/features/interpreterSelection.ts b/src/features/interpreterSelection.ts index bd09b5bc4..a0ed228fb 100644 --- a/src/features/interpreterSelection.ts +++ b/src/features/interpreterSelection.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as fs from 'fs-extra'; import * as path from 'path'; import { commands, ConfigurationChangeEvent, Disposable, l10n, Uri } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from '../api'; @@ -11,6 +12,7 @@ import { EventNames } from '../common/telemetry/constants'; import { sendTelemetryEvent } from '../common/telemetry/sender'; import { resolveVariables } from '../common/utils/internalVariables'; import { normalizePath } from '../common/utils/pathUtils'; +import { isWindows } from '../common/utils/platformUtils'; import { showWarningMessage } from '../common/window.apis'; import { getConfiguration, @@ -27,6 +29,12 @@ import type { import type { InternalEnvironmentManager } from '../managers/common/registeredManagers'; import { NativeEnvInfo, NativePythonFinder } from '../managers/common/nativePythonFinder'; +/** + * The default value of `python.defaultInterpreterPath`. Like the Python extension, treat it as "not + * configured" so auto-discovery (which prefers a workspace venv) picks the interpreter. + */ +const DEFAULT_INTERPRETER_PATH_SETTING = 'python'; + /** * Result from the priority chain resolution. */ @@ -109,7 +117,11 @@ async function resolvePriorityChainCore( // PRIORITY 3: User-configured python.defaultInterpreterPath const userInterpreterPath = getUserConfiguredSetting('python', 'defaultInterpreterPath', scope); - if (userInterpreterPath) { + if (userInterpreterPath === DEFAULT_INTERPRETER_PATH_SETTING) { + traceVerbose( + `${logPrefix} defaultInterpreterPath is the default '${userInterpreterPath}', using auto-discovery`, + ); + } else if (userInterpreterPath) { const expandedInterpreterPath = resolveVariables(userInterpreterPath, scope); if (expandedInterpreterPath.includes('${')) { if (scope) { @@ -135,7 +147,7 @@ async function resolvePriorityChainCore( // Resolve relative paths against the workspace folder so the native finder doesn't // resolve them against an unrelated current working directory (which can produce a // malformed, duplicated path such as //.venv/...). - const absoluteInterpreterPath = toAbsoluteInterpreterPath(expandedInterpreterPath, scope); + const absoluteInterpreterPath = await toAbsoluteInterpreterPath(expandedInterpreterPath, scope); const resolved = await tryResolveInterpreterPath(nativeFinder, api, absoluteInterpreterPath, envManagers); if (resolved) { traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`); @@ -369,10 +381,10 @@ export async function applyInitialEnvironmentSelection( globalScopeDeferredRef.value = 'deferred'; } resolveGlobalScope() - .then(async (globalErrors) => { + .then((globalErrors) => { const liveGlobalErrors = filterLiveSettingErrors(globalErrors, envManagers); if (liveGlobalErrors.length > 0) { - await notifyUserOfSettingErrors(liveGlobalErrors); + notifyUserOfSettingErrorsInBackground(liveGlobalErrors); } }) .catch((err) => traceError(`[interpreterSelection] Background global scope resolution failed: ${err}`)); @@ -389,7 +401,7 @@ export async function applyInitialEnvironmentSelection( const liveErrors = filterLiveSettingErrors(allErrors, envManagers); if (liveErrors.length > 0) { - await notifyUserOfSettingErrors(liveErrors); + notifyUserOfSettingErrorsInBackground(liveErrors); } // Numeric values must go via the measures argument (properties are dropped). @@ -451,6 +463,12 @@ function filterLiveSettingErrors( }); } +function notifyUserOfSettingErrorsInBackground(errors: SettingResolutionError[]): void { + notifyUserOfSettingErrors(errors).catch((err) => + traceError(`[interpreterSelection] Failed to show setting error notification: ${err}`), + ); +} + async function notifyUserOfSettingErrors(errors: SettingResolutionError[]): Promise { if (errors.length === 0) { return; @@ -534,24 +552,77 @@ function getProjectSpecificEnvManager(projectManager: PythonProjectManager, scop /** * Resolve a (variable-expanded) interpreter path to an absolute path. * - * `python.defaultInterpreterPath` may be configured as a relative path (e.g. `.venv/bin/python`). - * The native finder resolves relative paths against its own working directory, which is unrelated - * to the workspace and can produce malformed paths (including a duplicated workspace segment). - * To avoid this, relative paths are resolved against the workspace folder identified by the scope. - * Global relative paths are left unchanged because there is no workspace context to resolve against. + * A bare command name such as `python3` refers to the interpreter found on `PATH`, as in a terminal. + * Other relative paths (e.g. `.venv/bin/python`) are resolved against the workspace folder + * identified by the scope, because the native finder would resolve them against its own, + * unrelated working directory and can produce malformed paths (including a duplicated + * workspace segment). Global relative paths are left unchanged because there is no workspace + * context to resolve against. * * @param interpreterPath - The interpreter path after variable substitution. * @param scope - The workspace folder URI, or undefined for global scope. * @returns An absolute interpreter path when possible, otherwise the input unchanged. */ -function toAbsoluteInterpreterPath(interpreterPath: string, scope: Uri | undefined): string { - if (path.isAbsolute(interpreterPath) || !scope) { +async function toAbsoluteInterpreterPath(interpreterPath: string, scope: Uri | undefined): Promise { + if (path.isAbsolute(interpreterPath)) { + return interpreterPath; + } + if (!/[\\/]/.test(interpreterPath)) { + const onPath = await findCommandOnPath(interpreterPath); + if (onPath) { + return onPath; + } + } + if (!scope) { return interpreterPath; } const workspaceFolder = getWorkspaceFolder(scope); return path.resolve(workspaceFolder?.uri.fsPath ?? scope.fsPath, interpreterPath); } +/** + * Finds an executable on `PATH` and returns its absolute path. + * + * On Windows only `.com` and `.exe` files are considered, as when a process is started without a + * shell; batch-file shims cannot be launched that way. Unlike `which`, this accepts Windows App + * Execution Aliases (the `python.exe` that the Microsoft Store and the Python install manager place + * in `WindowsApps`), which exist but cannot be `stat`ed. Relative `PATH` entries are skipped because + * they would resolve against the extension host's working directory. + */ +async function findCommandOnPath(command: string): Promise { + const extensions = isWindows() ? ['.com', '.exe'] : ['']; + const names = + isWindows() && extensions.some((ext) => command.toLowerCase().endsWith(ext)) + ? [command] + : extensions.map((ext) => command + ext); + for (const entry of (process.env.PATH ?? '').split(path.delimiter)) { + const dir = entry.replace(/^"(.*)"$/, '$1'); + if (!dir || !path.isAbsolute(dir)) { + continue; + } + for (const name of names) { + const candidate = path.join(dir, name); + if (await isCommandFile(candidate)) { + return candidate; + } + } + } + return undefined; +} + +async function isCommandFile(candidate: string): Promise { + try { + if (isWindows()) { + await fs.access(candidate, fs.constants.F_OK); + return true; + } + await fs.access(candidate, fs.constants.X_OK); + return (await fs.stat(candidate)).isFile(); + } catch { + return false; + } +} + /** * Try to resolve an interpreter path via nativeFinder and return a PriorityChainResult. * Returns undefined if resolution fails. diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 0582bb551..7c22b38df 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -162,7 +162,10 @@ interface EditAllManagerSettingsInternal { envManager: string; packageManager: string; } -export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Promise { +export async function setAllManagerSettings( + edits: EditAllManagerSettings[], + persistNewProjects = false, +): Promise { const noWorkspace: EditAllManagerSettingsInternal[] = []; const workspaces = new Map(); const projectEdits = edits.filter((e): e is EditAllManagerSettingsInternal => !!e.project); @@ -227,7 +230,10 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr if (index >= 0) { overrides.splice(index, 1); } - if (config.get('defaultEnvManager') !== e.envManager) { + const currentManager = persistNewProjects + ? config.inspect('defaultEnvManager')?.workspaceValue + : config.get('defaultEnvManager'); + if (currentManager !== e.envManager) { promises.push(config.update('defaultEnvManager', e.envManager, ConfigurationTarget.Workspace)); } if (config.get('defaultPackageManager') !== e.packageManager) { @@ -245,9 +251,10 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr if (overrides[index].path === '') { overrides[index].path = '.'; } - } else if (workspaceFile) { + } else if (workspaceFile || persistNewProjects) { // Use "." for workspace root instead of empty string - const relativePath = path.relative(w.uri.fsPath, pwPath).replace(/\\/g, '/'); + const projectPath = persistNewProjects ? e.project.uri.fsPath : pwPath; + const relativePath = path.relative(w.uri.fsPath, projectPath).replace(/\\/g, '/'); overrides.push({ path: relativePath || '.', envManager: e.envManager, @@ -989,6 +996,22 @@ export function getSettingWorkspaceScope(section: string, key: string, scope? return undefined; } +/** + * Determines whether the Python Environments extension should activate. + * + * Explicit values are resolved independently for each workspace folder using VS Code's + * workspace-folder > workspace > user precedence. The extension activates when at least + * one folder enables integration, or when no explicit value applies. + */ +export function isEnvironmentsExtensionEnabled(): boolean { + const workspaceFolders = workspaceApis.getWorkspaceFolders(); + const scopes = workspaceFolders?.length ? workspaceFolders.map((folder) => folder.uri) : [undefined]; + + return scopes.some( + (scope) => getSettingWorkspaceScope('python', 'useEnvironmentsExtension', scope) !== false, + ); +} + /** * Gets user-configured setting for user-scoped settings. * Only checks globalValue (ignores defaultValue). diff --git a/src/features/terminal/shellStartupActivationVariablesManager.ts b/src/features/terminal/shellStartupActivationVariablesManager.ts index 317b9ff76..78ed2079a 100644 --- a/src/features/terminal/shellStartupActivationVariablesManager.ts +++ b/src/features/terminal/shellStartupActivationVariablesManager.ts @@ -11,6 +11,8 @@ export interface ShellStartupActivationVariablesManager extends Disposable { export class ShellStartupActivationVariablesManagerImpl implements ShellStartupActivationVariablesManager { private readonly disposables: Disposable[] = []; + private globalRefreshGeneration = 0; + constructor( private readonly envCollection: GlobalEnvironmentVariableCollection, private readonly shellEnvsProviders: ShellEnvsProvider[], @@ -33,6 +35,7 @@ export class ShellStartupActivationVariablesManagerImpl implements ShellStartupA if (autoActType === ACT_TYPE_SHELL) { await this.initializeInternal(); } else { + ++this.globalRefreshGeneration; const workspaces = getWorkspaceFolders() ?? []; if (workspaces.length > 0) { workspaces.forEach((workspace) => { @@ -48,7 +51,14 @@ export class ShellStartupActivationVariablesManagerImpl implements ShellStartupA private async handleEnvironmentChange(e: DidChangeEnvironmentEventArgs) { const autoActType = getAutoActivationType(); - if (autoActType !== ACT_TYPE_SHELL || !e.uri) { + if (autoActType !== ACT_TYPE_SHELL) { + return; + } + if (!e.uri) { + if (!getWorkspaceFolders()?.length) { + ++this.globalRefreshGeneration; + this.updateGlobalEnvironment(e.new); + } return; } const wf = getWorkspaceFolder(e.uri); @@ -89,19 +99,25 @@ export class ShellStartupActivationVariablesManagerImpl implements ShellStartupA }); await Promise.all(promises); } else { + const generation = ++this.globalRefreshGeneration; const env = await this.api.getEnvironment(undefined); - await Promise.all( - this.shellEnvsProviders.map(async (provider) => { - if (env) { - provider.updateEnvVariables(this.envCollection, env); - } else { - provider.removeEnvVariables(this.envCollection); - } - }), - ); + if (generation !== this.globalRefreshGeneration || getWorkspaceFolders()?.length) { + return; + } + this.updateGlobalEnvironment(env); } } + private updateGlobalEnvironment(environment: DidChangeEnvironmentEventArgs['new']): void { + this.shellEnvsProviders.forEach((provider) => { + if (environment) { + provider.updateEnvVariables(this.envCollection, environment); + } else { + provider.removeEnvVariables(this.envCollection); + } + }); + } + public async initialize(): Promise { const autoActType = getAutoActivationType(); if (autoActType === ACT_TYPE_SHELL) { diff --git a/src/features/terminal/terminalEnvVarInjector.ts b/src/features/terminal/terminalEnvVarInjector.ts index 76b152b1b..f4946c696 100644 --- a/src/features/terminal/terminalEnvVarInjector.ts +++ b/src/features/terminal/terminalEnvVarInjector.ts @@ -29,6 +29,7 @@ export class TerminalEnvVarInjector implements Disposable { private disposables: Disposable[] = []; // Track which .env variables we've set for each workspace to avoid clearing shell activation variables private envVarKeys: Map> = new Map(); + private refreshGenerations: Map = new Map(); private envFileNotificationShown = false; constructor( @@ -49,19 +50,17 @@ export class TerminalEnvVarInjector implements Disposable { this.envVarManager.onDidChangeEnvironmentVariables((args) => { if (!args.uri) { // No specific URI, reload all workspaces - this.updateEnvironmentVariables().catch((error) => { + return this.updateEnvironmentVariables().catch((error) => { traceError('Failed to update environment variables:', error); }); - return; } const affectedWorkspace = getWorkspaceFolder(args.uri); if (!affectedWorkspace) { // No workspace folder found for this URI, reloading all workspaces - this.updateEnvironmentVariables().catch((error) => { + return this.updateEnvironmentVariables().catch((error) => { traceError('Failed to update environment variables:', error); }); - return; } // Check if env file injection is enabled when variables change @@ -76,14 +75,9 @@ export class TerminalEnvVarInjector implements Disposable { }); } - if (args.changeType === 2) { - // FileChangeType.Deleted - this.clearWorkspaceVariables(affectedWorkspace); - } else { - this.updateEnvironmentVariables(affectedWorkspace).catch((error) => { - traceError('Failed to update environment variables:', error); - }); - } + return this.updateEnvironmentVariables(affectedWorkspace).catch((error) => { + traceError('Failed to update environment variables:', error); + }); }), ); @@ -110,12 +104,17 @@ export class TerminalEnvVarInjector implements Disposable { */ private async updateEnvironmentVariables(workspaceFolder?: WorkspaceFolder): Promise { try { + let workspaceUpdates: { folder: WorkspaceFolder; generation: number }[]; if (workspaceFolder) { - // Update only the specified workspace + workspaceUpdates = [ + { + folder: workspaceFolder, + generation: this.beginWorkspaceRefresh(workspaceFolder), + }, + ]; traceVerbose( `TerminalEnvVarInjector: Updating environment variables for workspace: ${workspaceFolder.uri.fsPath}`, ); - await this.injectEnvironmentVariablesForWorkspace(workspaceFolder); } else { // No provided workspace - update all workspaces @@ -126,9 +125,14 @@ export class TerminalEnvVarInjector implements Disposable { } traceVerbose('TerminalEnvVarInjector: Updating environment variables for all workspaces'); - for (const folder of workspaceFolders) { - await this.injectEnvironmentVariablesForWorkspace(folder); - } + workspaceUpdates = workspaceFolders.map((folder) => ({ + folder, + generation: this.beginWorkspaceRefresh(folder), + })); + } + + for (const { folder, generation } of workspaceUpdates) { + await this.injectEnvironmentVariablesForWorkspace(folder, generation); } traceVerbose('TerminalEnvVarInjector: Environment variable injection completed'); @@ -140,7 +144,10 @@ export class TerminalEnvVarInjector implements Disposable { /** * Inject environment variables for a specific workspace. */ - private async injectEnvironmentVariablesForWorkspace(workspaceFolder: WorkspaceFolder): Promise { + private async injectEnvironmentVariablesForWorkspace( + workspaceFolder: WorkspaceFolder, + generation: number, + ): Promise { const workspaceUri = workspaceFolder.uri; const workspaceKey = workspaceUri.fsPath; @@ -159,6 +166,9 @@ export class TerminalEnvVarInjector implements Disposable { traceVerbose( `TerminalEnvVarInjector: Env file injection disabled for workspace: ${workspaceUri.fsPath}`, ); + if (!this.isCurrentWorkspaceRefresh(workspaceFolder, generation)) { + return; + } // Clear only the .env variables we previously set, not shell activation variables this.clearTrackedEnvVariables(envVarScope, workspaceKey); return; @@ -170,8 +180,16 @@ export class TerminalEnvVarInjector implements Disposable { : undefined; const defaultEnvFilePath: string = path.join(workspaceUri.fsPath, '.env'); - let activeEnvFilePath: string = resolvedEnvFilePath || defaultEnvFilePath; - if (activeEnvFilePath && (await fse.pathExists(activeEnvFilePath))) { + const activeEnvFilePath = + resolvedEnvFilePath && (await fse.pathExists(resolvedEnvFilePath)) + ? resolvedEnvFilePath + : (await fse.pathExists(defaultEnvFilePath)) + ? defaultEnvFilePath + : undefined; + if (!this.isCurrentWorkspaceRefresh(workspaceFolder, generation)) { + return; + } + if (activeEnvFilePath) { traceVerbose(`TerminalEnvVarInjector: Using env file: ${activeEnvFilePath}`); } else { traceVerbose( @@ -214,6 +232,17 @@ export class TerminalEnvVarInjector implements Disposable { } } + private beginWorkspaceRefresh(workspaceFolder: WorkspaceFolder): number { + const workspaceKey = workspaceFolder.uri.fsPath; + const generation = (this.refreshGenerations.get(workspaceKey) ?? 0) + 1; + this.refreshGenerations.set(workspaceKey, generation); + return generation; + } + + private isCurrentWorkspaceRefresh(workspaceFolder: WorkspaceFolder, generation: number): boolean { + return this.refreshGenerations.get(workspaceFolder.uri.fsPath) === generation; + } + /** * Offer to enable env file injection for the affected workspace folder or suppress future reminders. */ @@ -265,18 +294,6 @@ export class TerminalEnvVarInjector implements Disposable { return envVarCollection.getScoped(scope); } - /** - * Clear all environment variables for a workspace. - */ - private clearWorkspaceVariables(workspaceFolder: WorkspaceFolder): void { - try { - const scope = this.getEnvironmentVariableCollectionScoped({ workspaceFolder }); - scope.clear(); - } catch (error) { - traceError(`Failed to clear environment variables for workspace ${workspaceFolder.uri.fsPath}:`, error); - } - } - /** * Clear only the .env variables we've tracked, not shell activation variables. */ diff --git a/src/internal/pythonToolSupport.ts b/src/internal/pythonToolSupport.ts new file mode 100644 index 000000000..f87442b9d --- /dev/null +++ b/src/internal/pythonToolSupport.ts @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { CancellationError, CancellationToken, Disposable, l10n, Uri } from 'vscode'; +import * as path from 'path'; +import { isSameOrParentPath, normalizePath } from '../common/utils/pathUtils'; +import type { NativePythonEnvironmentKind, NativePythonFinder } from '../managers/common/nativePythonFinder'; +import type { + EnvironmentManager, + GetEnvironmentScope, + GetEnvironmentsScope, + Package, + PackageManagementOptions, + PackageManager, + PythonEnvironment, +} from '../types'; + +/** Built-in opt-in to noninteractive operations, independent of UI quickCreateConfig. */ +export const pythonToolSupport = Symbol('pythonToolSupport'); + +export interface PythonToolOperation { + readonly token: CancellationToken; + readonly baseEnvironment?: PythonEnvironment; + getGlobalEnvironments(): Promise; + managePackages(environment: PythonEnvironment, options: PackageManagementOptions): Promise; +} + +export interface EnvironmentToolSupport { + initialize(): Promise; + canConfigure?(scope: Uri): Promise; + get(scope: GetEnvironmentScope): Promise; + getEnvironments(scope: GetEnvironmentsScope): Promise; + resolve(scope: Uri): Promise; + resolveProject?(scope: Uri): Promise; + create?(scope: Uri, operation: PythonToolOperation): Promise; + set?(scope: Uri, environment: PythonEnvironment, token: CancellationToken): Promise; + describe?(environment: PythonEnvironment, token: CancellationToken): Promise; +} + +export interface PackageToolSupport { + manage( + environment: PythonEnvironment, + options: PackageManagementOptions, + token: CancellationToken, + scope: Uri, + ): Promise; + getPackages(environment: PythonEnvironment, token: CancellationToken, scope: Uri): Promise; +} + +export interface ToolEnvironmentManager extends EnvironmentManager { + readonly [pythonToolSupport]: EnvironmentToolSupport; +} + +export interface ToolPackageManager extends PackageManager { + readonly [pythonToolSupport]: PackageToolSupport; +} + +export function supportsEnvironmentTools(manager: EnvironmentManager): manager is ToolEnvironmentManager { + return pythonToolSupport in manager; +} + +export function supportsPackageTools(manager: PackageManager): manager is ToolPackageManager { + return pythonToolSupport in manager; +} + +export class PythonToolError extends Error { + constructor(public readonly code: string, message: string, public readonly environment?: PythonEnvironment) { + super(message); + this.name = 'PythonToolError'; + } +} + +/** + * Resolves an existing cached environment associated with the closest containing project. + * @param scope The file or project targeted by configuration. + * @param kind The native environment kind owned by the requesting manager. + * @param finder The shared, noninteractive discovery service. + * @param resolve The manager's noninteractive interpreter resolver. + * @returns The project's environment, or undefined when none was discovered. + */ +export async function resolveToolProjectEnvironment( + scope: Uri, + kind: NativePythonEnvironmentKind, + finder: NativePythonFinder, + resolve: (uri: Uri) => Promise, +): Promise { + const matches = (await finder.refresh(false)).flatMap((info) => + !('tool' in info) && + info.kind === kind && + info.project && + info.prefix && + info.executable && + isSameOrParentPath(info.project, scope.fsPath) + ? [{ + project: normalizePath(path.resolve(info.project)), + prefix: normalizePath(path.resolve(info.prefix)), + executable: info.executable, + }] + : [], + ); + const closest = matches.sort((a, b) => b.project.length - a.project.length)[0]; + if (!closest) { + return undefined; + } + const candidates = new Map( + matches.filter((info) => info.project === closest.project).map((info) => [info.prefix, info]), + ); + if (candidates.size > 1) { + throw new PythonToolError( + 'AMBIGUOUS_ENVIRONMENT', + l10n.t( + 'Multiple {0} environments belong to {1}. Configure pythonPath with the exact interpreter to use.', + kind, + scope.fsPath, + ), + ); + } + return resolve(Uri.file(closest.executable)); +} + +export function throwIfCancelled(token?: CancellationToken): void { + if (token?.isCancellationRequested) { + throw new CancellationError(); + } +} + +/** Waits cancellably; null disables the deadline when waiting behind an owned operation. */ +export async function waitForToolRead( + promise: PromiseLike, + token: CancellationToken, + timeoutMs: number | null = 30_000, +): Promise { + throwIfCancelled(token); + let timer: ReturnType | undefined; + let subscription: Disposable | undefined; + try { + const value = await new Promise((resolve, reject) => { + if (timeoutMs !== null) { + timer = setTimeout( + () => + reject( + new PythonToolError( + 'NOT_READY', + l10n.t( + 'Python environment discovery did not finish in time. Retry after discovery completes.', + ), + ), + ), + timeoutMs, + ); + } + subscription = token.onCancellationRequested(() => reject(new CancellationError())); + Promise.resolve(promise).then(resolve, reject); + if (token.isCancellationRequested) { + reject(new CancellationError()); + } + }); + throwIfCancelled(token); + return value; + } finally { + clearTimeout(timer); + subscription?.dispose(); + } +} diff --git a/src/internal/pythonTools.ts b/src/internal/pythonTools.ts new file mode 100644 index 000000000..85fe9847a --- /dev/null +++ b/src/internal/pythonTools.ts @@ -0,0 +1,713 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as fs from 'fs/promises'; +import { pathExists } from 'fs-extra'; +import * as path from 'path'; +import { CancellationError, CancellationToken, Disposable, l10n, Uri } from 'vscode'; +import { + CONDA_MANAGER_ID, + INLINE_SCRIPT_MANAGER_ID, + PYENV_MANAGER_ID, + SYSTEM_MANAGER_ID, + VENV_MANAGER_ID, +} from '../common/constants'; +import { traceError } from '../common/logging'; +import { createDeferred } from '../common/utils/deferred'; +import { resolveVariables } from '../common/utils/internalVariables'; +import { isSameOrParentPath, normalizePath } from '../common/utils/pathUtils'; +import { ProcessTerminationError, ProcessTimeoutError } from '../common/utils/processRunner'; +import { handlePythonPath } from '../common/utils/pythonPath'; +import { getWorkspaceFolder, getWorkspaceFolders, isWorkspaceTrusted } from '../common/workspace.apis'; +import type { EnvironmentManagers } from '../features/envManagers'; +import type { PythonProjectManager } from '../features/projectManager'; +import { getDefaultEnvManagerSetting, getProjectEnvironmentManagerSetting } from '../features/settings/settingHelpers'; +import { getUserConfiguredSetting } from '../helpers'; +import type { InternalEnvironmentManager, InternalPackageManager } from '../managers/common/registeredManagers'; +import type { PackageManagementOptions, PythonEnvironment } from '../types'; +import type { + PythonToolEnvironmentRequest, + PythonToolInstallRequest, + PythonToolQueryRequest, + PythonToolResult, + PythonToolsApi, +} from './pythonToolsApi'; +import { PythonToolError, PythonToolOperation, throwIfCancelled, waitForToolRead } from './pythonToolSupport'; + +interface ToolTarget { + readonly resource: Uri; + readonly isDirectory: boolean; +} + +export class PythonToolsApiImpl implements PythonToolsApi { + readonly version = 1; + private readonly pending = new Map>(); + + constructor(private readonly managers: EnvironmentManagers, private readonly projects: PythonProjectManager) {} + + configureEnvironment(request: PythonToolEnvironmentRequest, token: CancellationToken): Promise { + return this.run(request, token, async (target) => { + const pythonPath = + request.pythonPath === undefined ? undefined : parseAbsolutePath(request.pythonPath, 'pythonPath'); + const explicitEnvironment = pythonPath + ? await this.resolveInterpreter(pythonPath, undefined, token) + : undefined; + if (pythonPath && !explicitEnvironment) { + throw new PythonToolError( + 'INVALID_PYTHON', + l10n.t('No Python environment could be resolved at {0}.', pythonPath.fsPath), + ); + } + const initialManager = pythonPath + ? this.managers.getEnvironmentManager(target.resource) + : await this.getConfigurationManager(target, token); + const scope = this.selectionScope(target, initialManager); + return this.serialize(scope, token, async () => { + let environment: PythonEnvironment | undefined = explicitEnvironment; + let created = false; + try { + if (!pythonPath) { + let manager = await this.getConfigurationManager(target, token); + this.checkSelectionScope(target, manager, scope); + const selected = await this.getSelectedEnvironment(scope, manager, token); + environment = selected && (await this.isIsolated(selected, token)) ? selected : undefined; + if (!environment) { + manager = await this.getCreationManager(manager, token); + } + if (!environment && manager.tools?.resolveProject) { + environment = await waitForToolRead(manager.tools.resolveProject(scope), token); + } + if (!environment && (manager.id === VENV_MANAGER_ID || manager.id === CONDA_MANAGER_ID)) { + const prefix = Uri.file( + path.join( + this.creationRoot(target, manager).fsPath, + manager.id === VENV_MANAGER_ID ? '.venv' : '.conda', + ), + ); + if (await pathExists(prefix.fsPath)) { + const resolved = await waitForToolRead(manager.tools!.resolve(prefix), token); + environment = + resolved && + isSameOrParentPath(prefix.fsPath, resolved.sysPrefix) && + (await this.isIsolated(resolved, token)) + ? resolved + : undefined; + } + } + if (!environment) { + if (!manager.tools?.create) { + throw new PythonToolError( + 'UNSUPPORTED_CREATION', + l10n.t( + 'Manager {0} does not support non-interactive environment creation. Create an environment with that manager, then configure its Python path.', + manager.id, + ), + ); + } + throwIfCancelled(token); + environment = await manager.tools.create( + manager.id === INLINE_SCRIPT_MANAGER_ID + ? target.resource + : this.creationRoot(target, manager), + this.operation(target, token, selected), + ); + created = true; + } + } + if (!environment) { + throw new PythonToolError( + 'ENVIRONMENT_NOT_FOUND', + l10n.t('No environment was returned for {0}.', scope.fsPath), + ); + } + if (!pythonPath && !(await this.isIsolated(environment, token))) { + throw new PythonToolError( + 'INVALID_ENVIRONMENT', + l10n.t('The environment manager did not return an isolated project environment.'), + environment, + ); + } + await this.validateEnvironment(environment, token); + const described = await this.describeEnvironment(environment, token); + throwIfCancelled(token); + if (environment.envId.managerId !== INLINE_SCRIPT_MANAGER_ID) { + await this.ensureProject(scope); + } + this.checkSelectionScope( + target, + pythonPath + ? this.managers.getEnvironmentManager(target.resource) + : await this.getConfigurationManager(target, token), + scope, + ); + throwIfCancelled(token); + await this.managers.setEnvironmentForTools(scope, environment, token); + return { + status: 'success', + environment: described, + resourcePath: scope.fsPath, + created, + }; + } catch (error) { + throw environment ? this.withEnvironment(error, environment, 'CONFIGURATION_FAILED') : error; + } + }); + }); + } + + getEnvironment(request: PythonToolQueryRequest, token: CancellationToken): Promise { + return this.run(request, token, async (target) => { + if (request.includePackages !== undefined && typeof request.includePackages !== 'boolean') { + throw new PythonToolError('INVALID_REQUEST', l10n.t('includePackages must be a boolean.')); + } + const initialManager = await this.getManager(target.resource, token); + const scope = this.selectionScope(target, initialManager); + return this.serialize(scope, token, async () => { + const manager = await this.getManager(target.resource, token); + this.checkSelectionScope(target, manager, scope); + const environment = await this.getSelectedEnvironment(scope, manager, token); + if (!environment) { + throw new PythonToolError( + 'ENVIRONMENT_NOT_FOUND', + l10n.t( + 'No Python environment is available for {0}. Configure an environment first.', + scope.fsPath, + ), + ); + } + await this.validateEnvironment(environment, token); + if (!request.includePackages) { + return { + status: 'success', + environment: await this.describeEnvironment(environment, token), + resourcePath: scope.fsPath, + }; + } + try { + const packageScope = this.creationRoot(target, manager); + const packageManager = await this.getPackageManager(environment, packageScope, token); + const packages = await packageManager.tools!.getPackages( + environment, + token, + packageScope, + ); + return { + status: 'success', + environment: await this.describeEnvironment(environment, token), + resourcePath: scope.fsPath, + packages: packages.map((pkg) => ({ + name: pkg.name, + ...(pkg.version ? { version: String(pkg.version) } : {}), + })), + }; + } catch (error) { + throw this.withEnvironment(error, environment, 'PACKAGE_QUERY_FAILED'); + } + }); + }); + } + + installPackages(request: PythonToolInstallRequest, token: CancellationToken): Promise { + return this.run(request, token, async (target) => { + if ( + !Array.isArray(request.packages) || + request.packages.length === 0 || + request.packages.some( + (pkg) => + typeof pkg !== 'string' || !pkg.trim() || pkg.trim().startsWith('-') || /[\0\r\n]/.test(pkg), + ) + ) { + throw new PythonToolError( + 'INVALID_REQUEST', + l10n.t('packages must be a non-empty array of package specifications, not command-line options.'), + ); + } + const initialManager = await this.getManager(target.resource, token); + const scope = this.selectionScope(target, initialManager); + return this.serialize(scope, token, async () => { + const manager = await this.getManager(target.resource, token); + this.checkSelectionScope(target, manager, scope); + const environment = await this.getSelectedEnvironment(scope, manager, token); + if (environment?.envId.managerId === INLINE_SCRIPT_MANAGER_ID) { + throw new PythonToolError( + 'IMMUTABLE_ENVIRONMENT', + l10n.t( + "Inline-script environments are shared immutable caches. Edit the script's PEP 723 dependencies, then configure its environment again instead of installing packages directly.", + ), + environment, + ); + } + if (!environment) { + throw new PythonToolError( + 'ENVIRONMENT_NOT_CONFIGURED', + l10n.t('Configure an environment for {0} before installing packages.', scope.fsPath), + ); + } + if (!(await this.isIsolated(environment, token))) { + throw new PythonToolError( + 'ENVIRONMENT_NOT_ISOLATED', + l10n.t( + 'Global and base Python environments are not modified by package tools. Configure {0} without pythonPath to create a project environment, or select an existing isolated environment.', + scope.fsPath, + ), + environment, + ); + } + await this.validateEnvironment(environment, token); + try { + const described = await this.describeEnvironment(environment, token); + await this.operation(target, token).managePackages(environment, { + install: request.packages.map((pkg) => pkg.trim()), + runHeadless: true, + }); + return { + status: 'success', + environment: described, + resourcePath: scope.fsPath, + }; + } catch (error) { + throw this.withEnvironment(error, environment, 'PACKAGE_INSTALL_FAILED'); + } + }); + }); + } + + private async run( + request: unknown, + token: CancellationToken, + action: (target: ToolTarget) => Promise, + ): Promise { + let target: ToolTarget | undefined; + try { + if ( + !token || + typeof token.isCancellationRequested !== 'boolean' || + typeof token.onCancellationRequested !== 'function' + ) { + throw new PythonToolError('INVALID_REQUEST', l10n.t('A cancellation token is required.')); + } + throwIfCancelled(token); + if (!isWorkspaceTrusted()) { + throw new PythonToolError( + 'WORKSPACE_UNTRUSTED', + l10n.t('Trust this workspace before running Python environment tools.'), + ); + } + if (!request || typeof request !== 'object' || Array.isArray(request)) { + throw new PythonToolError('INVALID_REQUEST', l10n.t('An environment tool request object is required.')); + } + target = await this.resolveTarget('resourcePath' in request ? request.resourcePath : undefined, token); + return await action(target); + } catch (error) { + const terminationFailed = + error instanceof ProcessTerminationError || + (error instanceof PythonToolError && error.code === 'PROCESS_TERMINATION_FAILED'); + if (!terminationFailed && (error instanceof CancellationError || token?.isCancellationRequested)) { + throw new CancellationError(); + } + traceError('Python environment tool failed:', error); + return { + status: 'error', + code: + error instanceof PythonToolError + ? error.code + : error instanceof ProcessTerminationError + ? 'PROCESS_TERMINATION_FAILED' + : error instanceof ProcessTimeoutError + ? 'TIMEOUT' + : 'OPERATION_FAILED', + message: error instanceof Error ? error.message : String(error), + ...(error instanceof PythonToolError && error.environment ? { environment: error.environment } : {}), + ...(target ? { resourcePath: target.resource.fsPath } : {}), + }; + } + } + + private async resolveTarget(resourcePath: unknown, token: CancellationToken): Promise { + let resource: Uri; + if (resourcePath === undefined) { + const folders = getWorkspaceFolders() ?? []; + if (folders.length !== 1) { + throw new PythonToolError( + folders.length === 0 ? 'NO_WORKSPACE' : 'AMBIGUOUS_RESOURCE', + folders.length === 0 + ? l10n.t('Open a workspace folder and provide its absolute resourcePath.') + : l10n.t( + 'Specify resourcePath to choose one of these workspace folders: {0}', + folders.map((folder) => folder.uri.fsPath).join(', '), + ), + ); + } + resource = folders[0].uri; + } else { + resource = parseAbsolutePath(resourcePath, 'resourcePath'); + } + if (resource.scheme !== 'file' || !getWorkspaceFolder(resource)) { + throw new PythonToolError( + 'INVALID_RESOURCE', + l10n.t('Open {0} in a local workspace folder before configuring its environment.', resource.fsPath), + ); + } + const stat = await waitForToolRead(fs.stat(resource.fsPath), token); + if (!stat.isFile() && !stat.isDirectory()) { + throw new PythonToolError('INVALID_RESOURCE', l10n.t('resourcePath must identify a file or directory.')); + } + return { resource, isDirectory: stat.isDirectory() }; + } + + private async getConfigurationManager( + target: ToolTarget, + token: CancellationToken, + ): Promise { + const inline = this.managers.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID); + if ( + !target.isDirectory && + inline?.tools?.canConfigure && + (await waitForToolRead(inline.tools.canConfigure(target.resource), token)) + ) { + await waitForToolRead(inline.tools.initialize(), token); + return inline; + } + return this.getManager(target.resource, token); + } + + private async getManager(scope: Uri, token: CancellationToken): Promise { + const project = this.projects.get(scope); + const explicitManagerId = + getProjectEnvironmentManagerSetting(this.projects, project?.uri ?? scope) ?? + getUserConfiguredSetting('python-envs', 'defaultEnvManager', scope); + const configuredId = explicitManagerId ?? getDefaultEnvManagerSetting(this.projects, scope); + try { + await this.waitForRegistration( + () => this.managers.getEnvironmentManager(configuredId), + this.managers.onDidChangeEnvironmentManager, + token, + ); + } catch (error) { + if (error instanceof PythonToolError && error.code === 'NOT_READY') { + throw new PythonToolError( + 'UNSUPPORTED_MANAGER', + l10n.t( + 'The environment manager {0} is not registered. Install the extension that provides it, or configure a different environment manager.', + configuredId, + ), + ); + } + throw error; + } + const routed = this.managers.getEnvironmentManager(scope); + const manager = + routed?.id === INLINE_SCRIPT_MANAGER_ID || !explicitManagerId + ? routed + : this.managers.getEnvironmentManager(explicitManagerId); + if (!manager?.tools) { + throw new PythonToolError( + 'UNSUPPORTED_MANAGER', + l10n.t( + 'The selected environment manager {0} does not provide non-interactive tool support.', + manager?.id ?? configuredId, + ), + ); + } + await waitForToolRead(manager.tools.initialize(), token); + return manager; + } + + private async getSelectedEnvironment( + scope: Uri, + manager: InternalEnvironmentManager, + token: CancellationToken, + ): Promise { + const selectedEnvironment = this.managers.getLastKnownEnvironment(scope); + const interpreterSetting = getUserConfiguredSetting('python', 'defaultInterpreterPath', scope); + const hasManagerSetting = + getProjectEnvironmentManagerSetting(this.projects, this.projects.get(scope)?.uri ?? scope) || + getUserConfiguredSetting('python-envs', 'defaultEnvManager', scope); + if (interpreterSetting && !hasManagerSetting && !selectedEnvironment) { + const interpreter = parseAbsolutePath( + resolveVariables(interpreterSetting, scope), + 'python.defaultInterpreterPath', + ); + const environment = await this.resolveInterpreter(interpreter, manager, token); + if (!environment) { + throw new PythonToolError( + 'INVALID_PYTHON', + l10n.t('The configured interpreter at {0} could not be resolved.', interpreter.fsPath), + ); + } + return environment; + } + return waitForToolRead(manager.tools!.get(scope), token); + } + + private async isIsolated(environment: PythonEnvironment, token: CancellationToken): Promise { + if (environment.envId.managerId === INLINE_SCRIPT_MANAGER_ID) { + return true; + } + if (environment.envId.managerId === CONDA_MANAGER_ID) { + return environment.name !== 'base' || environment.group === 'Prefix'; + } + return waitForToolRead(pathExists(path.join(environment.sysPrefix, 'pyvenv.cfg')), token); + } + + private async getCreationManager( + manager: InternalEnvironmentManager, + token: CancellationToken, + ): Promise { + if (manager.id !== SYSTEM_MANAGER_ID && manager.id !== PYENV_MANAGER_ID) { + return manager; + } + const venv = await this.waitForRegistration( + () => this.managers.getEnvironmentManager(VENV_MANAGER_ID), + this.managers.onDidChangeEnvironmentManager, + token, + ); + if (!venv.tools?.create) { + throw new PythonToolError( + 'UNSUPPORTED_CREATION', + l10n.t('The venv manager is not available to create an isolated project environment.'), + ); + } + await waitForToolRead(venv.tools.initialize(), token); + return venv; + } + + private selectionScope(target: ToolTarget, manager: InternalEnvironmentManager | undefined): Uri { + return target.isDirectory || manager?.id === INLINE_SCRIPT_MANAGER_ID + ? target.resource + : this.projects.get(target.resource)?.uri ?? Uri.file(path.dirname(target.resource.fsPath)); + } + + private checkSelectionScope( + target: ToolTarget, + manager: InternalEnvironmentManager | undefined, + expected: Uri, + ): void { + if (normalizePath(this.selectionScope(target, manager).fsPath) !== normalizePath(expected.fsPath)) { + throw new PythonToolError( + 'RESOURCE_CHANGED', + l10n.t('The project scope changed while this operation was queued. Retry the operation.'), + ); + } + } + + private creationRoot(target: ToolTarget, manager: InternalEnvironmentManager | undefined): Uri { + const scope = this.selectionScope(target, manager); + if (manager?.id === INLINE_SCRIPT_MANAGER_ID) { + return Uri.file(path.dirname(target.resource.fsPath)); + } + const project = this.projects.get(scope); + return target.isDirectory + ? scope + : project && project.uri.toString() !== target.resource.toString() + ? project.uri + : Uri.file(path.dirname(scope.fsPath)); + } + + private async ensureProject(scope: Uri): Promise { + if ( + this.projects + .getProjects() + .some((project) => normalizePath(project.uri.fsPath) === normalizePath(scope.fsPath)) + ) { + return; + } + await this.projects.add(this.projects.create(path.basename(scope.fsPath), scope), { persistSettings: false }); + } + + private async resolveInterpreter( + interpreter: Uri, + preferred: InternalEnvironmentManager | undefined, + token: CancellationToken, + ): Promise { + await this.waitForRegistration( + () => this.managers.getEnvironmentManager(SYSTEM_MANAGER_ID), + this.managers.onDidChangeEnvironmentManager, + token, + ); + const available: InternalEnvironmentManager[] = []; + for (const manager of this.managers.managers.filter((candidate) => candidate.tools)) { + try { + await waitForToolRead(manager.tools!.initialize(), token); + available.push(manager); + } catch (error) { + if (error instanceof CancellationError) { + throw error; + } + traceError(`Skipping unavailable interpreter resolver ${manager.id}:`, error); + } + } + if (available.length === 0) { + throw new PythonToolError('NOT_READY', l10n.t('No supported Python interpreter resolver is ready.')); + } + return waitForToolRead( + handlePythonPath( + interpreter, + available, + preferred && available.includes(preferred) ? [preferred] : [], + undefined, + token, + true, + ), + token, + ); + } + + private async validateEnvironment(environment: PythonEnvironment, token: CancellationToken): Promise { + const executable = environment.execInfo?.run.executable; + if (!executable || !path.isAbsolute(executable) || environment.version === 'no-python') { + throw new PythonToolError( + 'INVALID_ENVIRONMENT', + l10n.t('The selected environment does not have a usable Python executable.'), + environment, + ); + } + try { + const stat = await waitForToolRead(fs.stat(executable), token); + if (!stat.isFile()) { + throw new Error(l10n.t('The Python executable is not a file.')); + } + } catch (error) { + throw this.withEnvironment(error, environment, 'INVALID_ENVIRONMENT'); + } + } + + private async describeEnvironment( + environment: PythonEnvironment, + token: CancellationToken, + ): Promise { + const describe = this.managers.getEnvironmentManager(environment)?.tools?.describe; + return describe ? waitForToolRead(describe(environment, token), token) : environment; + } + + private operation( + target: ToolTarget, + token: CancellationToken, + baseEnvironment?: PythonEnvironment, + ): PythonToolOperation { + return { + token, + baseEnvironment, + getGlobalEnvironments: async () => { + const environments: PythonEnvironment[] = []; + for (const id of [SYSTEM_MANAGER_ID, CONDA_MANAGER_ID, PYENV_MANAGER_ID]) { + const manager = this.managers.getEnvironmentManager(id); + if (manager?.tools) { + await waitForToolRead(manager.tools.initialize(), token); + environments.push(...(await waitForToolRead(manager.tools.getEnvironments('global'), token))); + } + } + return environments; + }, + managePackages: async (environment: PythonEnvironment, options: PackageManagementOptions) => { + const scope = this.creationRoot(target, this.managers.getEnvironmentManager(target.resource)); + const manager = await this.getPackageManager(environment, scope, token); + throwIfCancelled(token); + await manager.tools!.manage(environment, { ...options, runHeadless: true }, token, scope); + }, + }; + } + + private async getPackageManager( + environment: PythonEnvironment, + scope: Uri, + token: CancellationToken, + ): Promise { + const preferredManagerId = this.managers.getEnvironmentManager(environment)?.preferredPackageManagerId; + const manager = await this.waitForRegistration( + () => { + const scoped = this.managers.getPackageManager(scope); + return scoped?.id === preferredManagerId ? scoped : this.managers.getPackageManager(environment); + }, + (listener) => + Disposable.from( + this.managers.onDidChangePackageManager(listener), + this.managers.onDidChangeProjectPackageManager(listener), + ), + token, + ); + if (!manager.tools) { + throw new PythonToolError( + 'UNSUPPORTED_PACKAGE_MANAGER', + l10n.t('Package manager {0} does not provide non-interactive tool support.', manager.id), + environment, + ); + } + return manager; + } + + private async waitForRegistration( + get: () => T | undefined, + subscribe: (listener: () => void) => Disposable, + token: CancellationToken, + ): Promise { + const existing = get(); + if (existing) { + return existing; + } + let subscription: Disposable | undefined; + try { + const pending = new Promise((resolve) => { + const check = () => { + const value = get(); + if (value) { + resolve(value); + } + }; + subscription = subscribe(check); + check(); + }); + return await waitForToolRead(pending, token); + } finally { + subscription?.dispose(); + } + } + + private async serialize(scope: Uri, token: CancellationToken, action: () => Promise): Promise { + const key = normalizePath(path.resolve(scope.fsPath)); + const prior = this.pending.get(key) ?? Promise.resolve(); + const release = createDeferred(); + const tail = prior.then(() => release.promise); + this.pending.set(key, tail); + void tail.then(() => { + if (this.pending.get(key) === tail) { + this.pending.delete(key); + } + }); + try { + await waitForToolRead(prior, token, null); + throwIfCancelled(token); + return await action(); + } finally { + release.resolve(); + } + } + + private withEnvironment(error: unknown, environment: PythonEnvironment, code: string): Error { + if (error instanceof CancellationError) { + return error; + } + return new PythonToolError( + error instanceof PythonToolError + ? error.code + : error instanceof ProcessTerminationError + ? 'PROCESS_TERMINATION_FAILED' + : error instanceof ProcessTimeoutError + ? 'TIMEOUT' + : code, + error instanceof Error ? error.message : String(error), + environment, + ); + } +} + +function parseAbsolutePath(value: unknown, name: string): Uri { + if (typeof value !== 'string' || !value.trim() || /[\0\r\n]/.test(value)) { + throw new PythonToolError('INVALID_REQUEST', l10n.t('{0} must be an absolute path or file URI.', name)); + } + const uri = path.isAbsolute(value) ? Uri.file(value) : /^file:/i.test(value) ? Uri.parse(value) : undefined; + if (!uri || uri.scheme !== 'file' || uri.query || uri.fragment || !path.isAbsolute(uri.fsPath)) { + throw new PythonToolError('INVALID_REQUEST', l10n.t('{0} must be an absolute path or file URI.', name)); + } + return uri; +} diff --git a/src/internal/pythonToolsApi.ts b/src/internal/pythonToolsApi.ts new file mode 100644 index 000000000..4a9d998a3 --- /dev/null +++ b/src/internal/pythonToolsApi.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import type { CancellationToken } from 'vscode'; +import type { PythonEnvironment } from '../types'; + +/** Private __pythonTools v1 contract, excluded from the published API. */ +export interface PythonToolEnvironmentRequest { + resourcePath?: string; + pythonPath?: string; +} + +export interface PythonToolQueryRequest { + resourcePath?: string; + includePackages?: boolean; +} + +export interface PythonToolInstallRequest { + resourcePath?: string; + packages: string[]; +} + +export type PythonToolResult = + | { + status: 'success'; + environment: PythonEnvironment; + resourcePath?: string; + created?: boolean; + packages?: { name: string; version?: string }[]; + } + | { + status: 'error'; + code: string; + message: string; + environment?: PythonEnvironment; + resourcePath?: string; + }; + +export interface PythonToolsApi { + readonly version: 1; + configureEnvironment(request: PythonToolEnvironmentRequest, token: CancellationToken): Promise; + getEnvironment(request: PythonToolQueryRequest, token: CancellationToken): Promise; + installPackages(request: PythonToolInstallRequest, token: CancellationToken): Promise; +} diff --git a/src/managers/base/commands/packageManagerCommand.ts b/src/managers/base/commands/packageManagerCommand.ts index 3449e7665..9bba7506c 100644 --- a/src/managers/base/commands/packageManagerCommand.ts +++ b/src/managers/base/commands/packageManagerCommand.ts @@ -7,6 +7,8 @@ import { getConfiguration } from '../../../common/workspace.apis'; */ export interface BaseExecuteArgs { cancellationToken?: CancellationToken; + strict?: boolean; + toolExecution?: boolean; } /** @@ -17,6 +19,8 @@ export interface CommandConstructorOptions { uvExecutable?: string; cwd?: string; log?: LogOutputChannel; + cancellationToken?: CancellationToken; + toolExecution?: boolean; } /** diff --git a/src/managers/builtin/commands/factory.ts b/src/managers/builtin/commands/factory.ts index cd7fe993d..a0945d4b3 100644 --- a/src/managers/builtin/commands/factory.ts +++ b/src/managers/builtin/commands/factory.ts @@ -11,8 +11,9 @@ export async function createPipOrUvCommandWithKind( PipCommand: CommandConstructor

, UvCommand: CommandConstructor, ): Promise> { - if (await shouldUseUv(options.log, environmentPath)) { - const uvExecutable = await getUvExecutable(options.log, environmentPath); + const token = options.toolExecution ? options.cancellationToken : undefined; + if (await shouldUseUv(options.log, environmentPath, environmentPath, token)) { + const uvExecutable = await getUvExecutable(options.log, environmentPath, token); if (!uvExecutable) { throw new Error(`uv became unavailable for environment: ${environmentPath}`); } diff --git a/src/managers/builtin/commands/install.ts b/src/managers/builtin/commands/install.ts index 9ba7c69c2..cbcda7d66 100644 --- a/src/managers/builtin/commands/install.ts +++ b/src/managers/builtin/commands/install.ts @@ -22,10 +22,11 @@ export class PipInstallCommand extends InstallCommand { await runPython( this.pythonExecutable, this.buildCommand(executeArgs), - undefined, + executeArgs.toolExecution ? this.cwd : undefined, this.log, executeArgs.cancellationToken, - this.timeout, + this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined), + executeArgs.toolExecution, ); } } @@ -47,6 +48,14 @@ export class UvInstallCommand extends InstallCommand { } async execute(executeArgs: InstallExecuteArgs): Promise { - await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout, this.uvExecutable); + await runUV( + this.buildCommand(executeArgs), + executeArgs.toolExecution ? this.cwd : undefined, + this.log, + executeArgs.cancellationToken, + this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined), + this.uvExecutable, + executeArgs.toolExecution, + ); } } diff --git a/src/managers/builtin/commands/list.ts b/src/managers/builtin/commands/list.ts index afd89ade9..5a3f94fd4 100644 --- a/src/managers/builtin/commands/list.ts +++ b/src/managers/builtin/commands/list.ts @@ -20,19 +20,32 @@ export class PipListCommand extends ListCommand { this.log, executeArgs?.cancellationToken, this.timeout, + executeArgs?.toolExecution, ); let json: unknown; try { json = JSON.parse(output); } catch (e) { this.log?.error(`Failed to parse pip list output: ${e}`); + if (executeArgs?.strict) { + throw e; + } return []; } if (!Array.isArray(json)) { this.log?.error('Invalid output from pip list command'); + if (executeArgs?.strict) { + throw new Error('Invalid output from pip list command'); + } return []; } + if ( + executeArgs?.strict && + json.some((item) => !item || typeof item.name !== 'string' || typeof item.version !== 'string') + ) { + throw new Error('Invalid package in pip list output'); + } return json .filter(({ name, version }) => !!name && !!version) .map(({ name, version }) => ({ @@ -62,19 +75,32 @@ export class UvListCommand extends ListCommand { executeArgs?.cancellationToken, this.timeout, this.uvExecutable, + executeArgs?.toolExecution, ); let json: unknown; try { json = JSON.parse(output); } catch (e) { this.log?.error(`Failed to parse uv pip list output: ${e}`); + if (executeArgs?.strict) { + throw e; + } return []; } if (!Array.isArray(json)) { this.log?.error('Invalid output from uv pip list command'); + if (executeArgs?.strict) { + throw new Error('Invalid output from uv pip list command'); + } return []; } + if ( + executeArgs?.strict && + json.some((item) => !item || typeof item.name !== 'string' || typeof item.version !== 'string') + ) { + throw new Error('Invalid package in uv pip list output'); + } return json .filter(({ name, version }) => !!name && !!version) .map(({ name, version }) => ({ diff --git a/src/managers/builtin/commands/uninstall.ts b/src/managers/builtin/commands/uninstall.ts index 8933898f6..7c61aacd8 100644 --- a/src/managers/builtin/commands/uninstall.ts +++ b/src/managers/builtin/commands/uninstall.ts @@ -18,7 +18,8 @@ export class PipUninstallCommand extends UninstallCommand { undefined, this.log, executeArgs.cancellationToken, - this.timeout, + this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined), + executeArgs.toolExecution, ); } } @@ -36,6 +37,14 @@ export class UvUninstallCommand extends UninstallCommand { } async execute(executeArgs: UninstallExecuteArgs): Promise { - await runUV(this.buildCommand(executeArgs), undefined, this.log, executeArgs.cancellationToken, this.timeout, this.uvExecutable); + await runUV( + this.buildCommand(executeArgs), + undefined, + this.log, + executeArgs.cancellationToken, + this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined), + this.uvExecutable, + executeArgs.toolExecution, + ); } } diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 82a4afb54..9211c5e52 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -5,9 +5,22 @@ import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred } from '../../common/utils/deferred'; import { getConfiguration, getWorkspaceFolder, isWorkspaceTrusted } from '../../common/workspace.apis'; +import { runLoggedProcess } from '../../common/utils/processRunner'; +import { throwIfCancelled, waitForToolRead } from '../../internal/pythonToolSupport'; import { getUvEnvironments } from './uvEnvironments'; let available = createDeferred(); +let uvExecutable = 'uv'; + +/** + * Uses an explicitly located uv executable for this extension-host session. + * Resets availability so the executable is verified before use. + * @param executable Command or absolute executable path to invoke. + */ +export function setUvExecutable(executable: string): void { + uvExecutable = executable; + resetUvInstallationCache(); +} /** * Reset the UV installation cache. @@ -20,42 +33,51 @@ export async function isUvInstalled(log?: LogOutputChannel): Promise { if (available.completed) { return available.promise; } - log?.info(`Running: uv --version`); - const proc = spawnProcess('uv', ['--version']); + const pending = available; + log?.info(`Running: ${uvExecutable} --version`); + const proc = spawnProcess(uvExecutable, ['--version']); proc.on('error', () => { - available.resolve(false); + pending.resolve(false); }); proc.stdout?.on('data', (d) => log?.info(d.toString())); proc.on('exit', (code) => { if (code === 0) { sendTelemetryEvent(EventNames.VENV_USING_UV); } - available.resolve(code === 0); + pending.resolve(code === 0); }); - return available.promise; + return pending.promise; } /** - * Resolves uv from the extension PATH, or from pyprojectx in the owning trusted workspace. + * Resolves uv from this session's installation or PATH, or from pyprojectx in the owning trusted workspace. * @param log Optional command log. * @param scope Path inside the workspace whose local uv should be considered. + * @param token Optional cancellation token for noninteractive tool requests. * @returns The command to spawn, or undefined when uv is unavailable. */ -export async function getUvExecutable(log?: LogOutputChannel, scope?: string): Promise { - if (await isUvInstalled(log)) { - return 'uv'; +export async function getUvExecutable( + log?: LogOutputChannel, + scope?: string, + token?: CancellationToken, +): Promise { + throwIfCancelled(token); + const installed = isUvInstalled(log); + if (token ? await waitForToolRead(installed, token) : await installed) { + return uvExecutable; } const workspaceFolder = scope && isWorkspaceTrusted() ? getWorkspaceFolder(Uri.file(scope)) : undefined; if (!workspaceFolder) { return undefined; } const executable = path.join(workspaceFolder.uri.fsPath, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); - return new Promise((resolve) => { + const probe = new Promise((resolve) => { log?.info(`Running: ${executable} --version`); const proc = spawnProcess(executable, ['--version']); proc.on('error', () => resolve(undefined)); proc.on('exit', (code) => resolve(code === 0 ? executable : undefined)); }); + return token ? waitForToolRead(probe, token) : probe; } /** @@ -63,14 +85,21 @@ export async function getUvExecutable(log?: LogOutputChannel, scope?: string): P * @param log - Optional log output channel for logging operations * @param envPath - Optional environment path to check against UV environments list * @param scope - Path used to locate the workspace's uv; defaults to the environment path. + * @param token - Optional cancellation token for noninteractive tool requests. * @returns True if uv should be used, false otherwise. For UV environments, returns true if uv is installed. For other environments, checks the 'python-envs.alwaysUseUv' setting and uv availability. */ -export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scope = envPath): Promise { +export async function shouldUseUv( + log?: LogOutputChannel, + envPath?: string, + scope = envPath, + token?: CancellationToken, +): Promise { + throwIfCancelled(token); if (envPath) { // always use uv if the given environment is stored as a uv env const uvEnvs = await getUvEnvironments(); if (uvEnvs.includes(envPath)) { - return (await getUvExecutable(log, scope)) !== undefined; + return (await getUvExecutable(log, scope, token)) !== undefined; } } @@ -79,7 +108,7 @@ export async function shouldUseUv(log?: LogOutputChannel, envPath?: string, scop const alwaysUseUv = config.get('alwaysUseUv', true); if (alwaysUseUv) { - return (await getUvExecutable(log, scope)) !== undefined; + return (await getUvExecutable(log, scope, token)) !== undefined; } return false; } @@ -90,8 +119,12 @@ export async function runUV( log?: LogOutputChannel, token?: CancellationToken, timeout?: number, - executable = 'uv', + executable = uvExecutable, + toolExecution = false, ): Promise { + if (toolExecution) { + return runLoggedProcess(executable, args, { cwd }, log, token, timeout); + } log?.info(`Running: ${executable} ${args.join(' ')}`); return new Promise((resolve, reject) => { const spawnOptions: { cwd?: string; timeout?: number } = { cwd }; @@ -146,7 +179,11 @@ export async function runPython( log?: LogOutputChannel, token?: CancellationToken, timeout?: number, + toolExecution = false, ): Promise { + if (toolExecution) { + return runLoggedProcess(python, args, { cwd }, log, token, timeout, true); + } log?.info(`Running: ${python} ${args.join(' ')}`); return new Promise((resolve, reject) => { const proc = spawnProcess(python, args, { cwd: cwd, timeout }); diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 61eb7ec2d..3067a06a6 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -5,6 +5,8 @@ import type { Stats } from 'fs'; import * as fs from 'fs-extra'; import * as path from 'path'; import { + CancellationError, + CancellationToken, Disposable, Event, EventEmitter, @@ -94,6 +96,14 @@ import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; import { createWithProgress, hasMinimumPathDepth, isDriveRoot, resolveVenvPythonEnvironmentPath } from '../venvUtils'; import { InlineAssociationAccessor, InlineScriptAssociationStore } from './associationStore'; +import { + EnvironmentToolSupport, + PythonToolError, + PythonToolOperation, + pythonToolSupport, + throwIfCancelled, + waitForToolRead, +} from '../../../internal/pythonToolSupport'; const BASE_INTERPRETER_MANAGER_IDS = new Set([SYSTEM_MANAGER_ID, CONDA_MANAGER_ID, PYENV_MANAGER_ID]); @@ -179,6 +189,7 @@ interface DiscoveryRefreshPass { } interface PendingCreationContext { + operation?: PythonToolOperation; promise: Promise; sourceMetadataIdentityHashes?: readonly string[]; hasStartedRecordingSourceMetadataIdentityHashes: boolean; @@ -333,6 +344,36 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { true, ); public readonly iconPath: IconPath = new ThemeIcon('file-code'); + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => {}, + canConfigure: async (scope) => { + const script = this.getScriptUri(scope); + if (!script) { + return false; + } + try { + return !!(await readInlineScriptMetadataFromFile(script, true)); + } catch (error) { + throw new PythonToolError('INVALID_PROJECT', getErrorMessage(error)); + } + }, + get: (scope) => this.get(scope), + getEnvironments: (scope) => this.getEnvironments(scope), + resolve: (scope) => this.resolve(scope), + create: async (scope, operation) => { + const environment = await this.create(scope, { quickCreate: true }, operation); + if (!environment) { + throw new PythonToolError( + 'CREATION_FAILED', + l10n.t( + 'Inline-script setup could not produce a usable environment. Check its PEP 723 metadata and the Python Environments output.', + ), + ); + } + return environment; + }, + set: (scope, environment, token) => this.set(scope, environment, token), + }; constructor( private readonly nativeFinder: NativePythonFinder, @@ -374,11 +415,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { async create( scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, + operation?: PythonToolOperation, ): Promise { + throwIfCancelled(operation?.token); this.activeCreateOperations += 1; try { return await this.waitForCacheMaintenance(async () => { try { + throwIfCancelled(operation?.token); const scriptUri = this.getScriptUri(scope); if (!scriptUri) { this.log.warn('Inline-script environment creation requires exactly one local file URI.'); @@ -388,6 +432,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const metadata = await readInlineScriptMetadataFromFile(scriptUri); if (!metadata) { + if (operation) { + throw new PythonToolError( + 'INVALID_PROJECT', + l10n.t('No valid PEP 723 metadata was found in {0}.', scriptUri.fsPath), + ); + } this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); return undefined; } @@ -402,13 +452,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - const setupKey = this.getPendingSetupKey(scriptUri, metadata, packages, options); + const setupKey = `${operation ? 'tool:' : ''}${this.getPendingSetupKey(scriptUri, metadata, packages, options)}`; const pending = this.pendingSetups.get(setupKey); if (pending) { - return await pending; + return operation + ? await waitForToolRead(pending, operation.token, CACHE_LOCK_TIMEOUT_MS) + : await pending; } - const setup = this.createForScript(scriptUri, metadata, packages, options); + const setup = this.createForScript(scriptUri, metadata, packages, options, operation); this.pendingSetups.set(setupKey, setup); try { return await setup; @@ -420,6 +472,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } catch (error) { this.sendInlineScriptEnvErrorTelemetry('setup-failure'); this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); + if (operation) { + throw error; + } return undefined; } }); @@ -433,8 +488,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { metadata: InlineScriptMetadata, packages: readonly string[], options?: CreateEnvironmentOptions, + operation?: PythonToolOperation, ): Promise { - const baseSelection = await this.selectOrInstallBaseInterpreter(metadata, options?.quickCreate === true); + throwIfCancelled(operation?.token); + const baseSelection = await this.selectOrInstallBaseInterpreter( + metadata, + options?.quickCreate === true, + operation, + ); if (!baseSelection.selectedBase) { if (baseSelection.errorCategory) { this.sendInlineScriptEnvErrorTelemetry(baseSelection.errorCategory); @@ -447,6 +508,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.log.warn( `No compatible Python is available for inline-script environment creation: ${scriptUri.fsPath}.`, ); + if (operation) { + throw new PythonToolError( + 'MISSING_PYTHON', + l10n.t( + 'Install a Python interpreter compatible with requires-python {0} before setting up {1}.', + metadata.requiresPython ?? '3', + scriptUri.fsPath, + ), + ); + } return undefined; } const selectedBase = baseSelection.selectedBase; @@ -459,10 +530,18 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const sourceMetadataIdentityHash = metadataIdentity ? hashSourceMetadataIdentity(metadataIdentity) : undefined; const pending = this.pendingCreations.get(cacheKey); if (pending) { + if (operation && !pending.operation) { + throw new PythonToolError( + 'ENVIRONMENT_BUSY', + l10n.t('A human-initiated setup is using this inline-script environment. Retry when it finishes.'), + ); + } const joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes = pending.hasStartedRecordingSourceMetadataIdentityHashes; this.addPendingCreationSourceMetadataIdentityHash(pending, sourceMetadataIdentityHash); - const environment = await pending.promise; + const environment = operation + ? await waitForToolRead(pending.promise, operation.token, CACHE_LOCK_TIMEOUT_MS) + : await pending.promise; if (!environment) { this.notePendingCreationFailure(scriptUri, metadata, pending); } @@ -475,6 +554,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); } const pendingCreation: PendingCreationContext = { + operation, promise: Promise.resolve(undefined), sourceMetadataIdentityHashes: mergeSourceMetadataIdentityHashes(undefined, sourceMetadataIdentityHash), hasStartedRecordingSourceMetadataIdentityHashes: false, @@ -551,8 +631,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private async selectOrInstallBaseInterpreter( metadata: InlineScriptMetadata, quickCreate: boolean, + operation?: PythonToolOperation, ): Promise { - const selection = await this.selectBaseInterpreter(metadata); + const selection = await this.selectBaseInterpreter(metadata, operation); if (selection.selectedBase) { return { selectedBase: selection.selectedBase }; } @@ -618,8 +699,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return []; } - async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { - return this.waitForCacheMaintenance(() => this.enqueueSelection(() => this.setInternal(scope, environment))); + async set(scope: SetEnvironmentScope, environment?: PythonEnvironment, token?: CancellationToken): Promise { + throwIfCancelled(token); + return this.waitForCacheMaintenance(() => + this.enqueueSelection(() => this.setInternal(scope, environment, token)), + ); } async get(scope: GetEnvironmentScope): Promise { @@ -1074,7 +1158,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return uri?.scheme === 'file' ? uri : undefined; } - private async setInternal(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { + private async setInternal( + scope: SetEnvironmentScope, + environment?: PythonEnvironment, + token?: CancellationToken, + ): Promise { + throwIfCancelled(token); const scripts = this.getScriptUris(scope); if (scripts.length === 0) { return; @@ -1139,6 +1228,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } try { + throwIfCancelled(token); const persistenceUpdates = updates.filter((update) => update.needsPersistence); if (persistenceUpdates.length > 0) { await this.updatePersistedAssociations( @@ -2280,7 +2370,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.clearValidatedRouteableState(uri); return; } - this.routingRegistry.setValidatedAssociation(uri, true); + this.routingRegistry.setValidatedAssociation(uri, true, environment.version); } private async updateValidatedStateForSelection(script: ScriptReference): Promise { @@ -2315,6 +2405,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { this.routingRegistry.setValidatedAssociation( script.uri, this.routingRegistry.getMetadataIdentity(script.uri) === savedMetadata.identity, + environment.version, ); } @@ -3242,12 +3333,20 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); } - private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise { + private async selectBaseInterpreter( + metadata: InlineScriptMetadata, + operation?: PythonToolOperation, + ): Promise { let globalEnvironments: readonly PythonEnvironment[] = []; let discoveryFailed = false; try { - globalEnvironments = await this.api.getEnvironments('global'); + globalEnvironments = operation + ? await operation.getGlobalEnvironments() + : await this.api.getEnvironments('global'); } catch (error) { + if (operation) { + throw error; + } discoveryFailed = true; this.log.warn(`Unable to query discovered base interpreters: ${getErrorMessage(error)}`); } @@ -3530,10 +3629,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { envDir: Uri, action: (lock: AcquiredFileLock) => Promise, timeoutMs = CACHE_LOCK_TIMEOUT_MS, + token?: CancellationToken, ): Promise { - const lock = await this.acquireCacheEntryLock(envDir.fsPath, timeoutMs); + const lock = await this.acquireCacheEntryLock(envDir.fsPath, timeoutMs, token); try { + throwIfCancelled(token); return await action(lock); + } catch (error) { + if (error instanceof PythonToolError && error.code === 'PROCESS_TERMINATION_FAILED') { + await lock.retain(); + } + throw error; } finally { try { await lock.release(); @@ -3547,7 +3653,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { * Reclaims a lock whose owning process is gone. Doing this *before* waiting is the point: * otherwise every later setup for the same cache key blocks for the whole timeout, then fails. */ - private async acquireCacheEntryLock(envDirPath: string, timeoutMs: number): Promise { + private async acquireCacheEntryLock( + envDirPath: string, + timeoutMs: number, + token?: CancellationToken, + ): Promise { + throwIfCancelled(token); try { if ((await inspectFileLock(envDirPath)) === 'stale' && (await reclaimFileLock(envDirPath))) { this.log.info( @@ -3564,7 +3675,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { code: 'ELOCKED', }); } - return acquireFileLock(envDirPath, { timeoutMs, retryIntervalMs: CACHE_LOCK_RETRY_MS }); + return acquireFileLock(envDirPath, { + timeoutMs, + retryIntervalMs: CACHE_LOCK_RETRY_MS, + ...(token ? { checkCancellation: () => throwIfCancelled(token) } : {}), + }); } private mergePendingCreationSourceMetadataIdentityHashes( @@ -3627,58 +3742,77 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); try { + throwIfCancelled(pendingCreation.operation?.token); await fs.ensureDir(cacheRoot.fsPath); - return await this.withCacheEntryLock(envDir, async () => { - const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase, pendingCreation); - if (cached.kind === 'reusable') { - this.sendInlineScriptEnvReuseHitTelemetry(dependencyCount); - return cached.environment; - } - if (cached.kind === 'uncertain') { - this.log.warn( - `Preserving an inline-script cache entry that could not be safely inspected: ${envDir.fsPath}`, + return await this.withCacheEntryLock( + envDir, + async () => { + const cached = await this.inspectCacheEntry( + cacheRoot, + envDir, + metadata, + selectedBase, + pendingCreation, ); - pendingCreation.failure = { category: 'setup-failure' }; - this.sendInlineScriptEnvErrorTelemetry('setup-failure'); - return undefined; - } - if (cached.kind === 'stale') { - if (!(await this.discardCacheEntry(envDir))) { - // The sidecar is gone, so the entry is inert; a later attempt retries the - // deletion once the files are released. + throwIfCancelled(pendingCreation.operation?.token); + if (cached.kind === 'reusable') { + this.sendInlineScriptEnvReuseHitTelemetry(dependencyCount); + return cached.environment; + } + if (cached.kind === 'uncertain') { + this.log.warn( + `Preserving an inline-script cache entry that could not be safely inspected: ${envDir.fsPath}`, + ); pendingCreation.failure = { category: 'setup-failure' }; this.sendInlineScriptEnvErrorTelemetry('setup-failure'); return undefined; } - } + if (cached.kind === 'stale') { + if (!(await this.discardCacheEntry(envDir))) { + // The sidecar is gone, so the entry is inert; a later attempt retries the + // deletion once the files are released. + pendingCreation.failure = { category: 'setup-failure' }; + this.sendInlineScriptEnvErrorTelemetry('setup-failure'); + return undefined; + } + } - const buildStartAtMs = Date.now(); - const build = await this.buildCacheEntry( - envDir, - cacheRoot, - packages, - selectedBase, - pendingCreation, - scriptUri, - ); - if (build.environment) { - this.sendInlineScriptEnvCreatedTelemetry(buildStartAtMs, dependencyCount); - return build.environment; - } - if (build.errorCategory) { - pendingCreation.failure = { - category: build.errorCategory, - ...(build.errorCategory === 'package-install-cancelled' ? { cancelled: true } : {}), - }; - this.sendInlineScriptEnvErrorTelemetry(build.errorCategory); - } - return undefined; - }); + const buildStartAtMs = Date.now(); + const build = await this.buildCacheEntry( + envDir, + cacheRoot, + packages, + selectedBase, + pendingCreation, + scriptUri, + ); + if (build.environment) { + this.sendInlineScriptEnvCreatedTelemetry(buildStartAtMs, dependencyCount); + return build.environment; + } + if (build.errorCategory) { + pendingCreation.failure = { + category: build.errorCategory, + ...(build.errorCategory === 'package-install-cancelled' ? { cancelled: true } : {}), + }; + this.sendInlineScriptEnvErrorTelemetry(build.errorCategory); + } + return undefined; + }, + CACHE_LOCK_TIMEOUT_MS, + pendingCreation.operation?.token, + ); } catch (error) { + if (pendingCreation.operation && (error instanceof CancellationError || error instanceof PythonToolError)) { + throw error; + } const category = this.getCreateOrReuseErrorCategory(error); pendingCreation.failure = { category }; this.sendInlineScriptEnvErrorTelemetry(category); this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`); + if (pendingCreation.operation) { + throw new PythonToolError('CREATION_FAILED', getErrorMessage(error)); + } return undefined; } } @@ -3780,6 +3914,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } const stampedAt = Date.now(); try { + throwIfCancelled(pendingCreation.operation?.token); pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true; const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes( sidecar.sourceMetadataIdentityHashes, @@ -3848,11 +3983,27 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { { progressTitle: l10n.t('Setting up environment for {0}', path.basename(scriptUri.fsPath)), nameStyle: 'inlineScript', + operation: pendingCreation.operation, }, ); } catch (error) { this.log.error(`Failed to build inline-script environment: ${getErrorMessage(error)}`); + if (error instanceof PythonToolError && error.code === 'PROCESS_TERMINATION_FAILED') { + throw error; + } await this.removeCacheEntry(envDir); + if (pendingCreation.operation) { + if (error instanceof CancellationError) { + throw error; + } + throw new PythonToolError( + error instanceof PythonToolError ? error.code : 'CREATION_FAILED', + l10n.t( + 'Inline-script setup failed and the incomplete cache entry was discarded: {0}', + getErrorMessage(error), + ), + ); + } return { errorCategory: 'install-failure' }; } @@ -3879,6 +4030,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { errorCategory: 'setup-failure' }; } try { + throwIfCancelled(pendingCreation.operation?.token); pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true; const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes( undefined, diff --git a/src/managers/builtin/main.ts b/src/managers/builtin/main.ts index 31d92c837..b58b9aeae 100644 --- a/src/managers/builtin/main.ts +++ b/src/managers/builtin/main.ts @@ -28,6 +28,9 @@ export async function registerSystemPythonFeatures( venvManager.watcherRefresh(); }); const activationWatcher = createFileSystemWatcher('{**/activate}', false, false, false); + // Deleting a folder is reported for the folder only, not for the files in it, so deleting a + // virtual environment folder never matches the activate script pattern above. + const deletionWatcher = createFileSystemWatcher('**/*', true, true, false); disposables.push( activationWatcher, activationWatcher.onDidCreate(() => { @@ -39,6 +42,12 @@ export async function registerSystemPythonFeatures( activationWatcher.onDidDelete(() => { venvDebouncedRefresh.trigger(); }), + deletionWatcher, + deletionWatcher.onDidDelete((uri) => { + if (venvManager.hasEnvironmentAt(uri.fsPath)) { + venvDebouncedRefresh.trigger(); + } + }), onDidDeleteFiles(() => { venvDebouncedRefresh.trigger(); }), diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index 291d452e4..f22ede104 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -11,6 +11,7 @@ import { MarkdownString, ProgressLocation, ThemeIcon, + Uri, } from 'vscode'; import { DidChangePackagesEventArgs, @@ -26,6 +27,7 @@ import { import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { PythonVersion } from '../../common/pythonVersion'; import { showErrorMessage, withProgress } from '../../common/window.apis'; +import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport'; import { CommandConstructorOptions } from '../base/commands/index'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { parsePackageSpecs } from '../common/packageUtils'; @@ -71,7 +73,25 @@ export class PipPackageManager implements PackageManager, Disposable { readonly tooltip?: string | MarkdownString; readonly iconPath?: IconPath; - async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise { + readonly [pythonToolSupport]: PackageToolSupport = { + manage: (environment, options, token, scope) => + this.manage(environment, { ...options, runHeadless: true }, token, scope), + getPackages: async (environment, token) => { + const packages = await this.fetchPackages(environment, false, token); + if (!packages) { + throw new Error('Package listing returned no result.'); + } + return packages; + }, + }; + + async manage( + environment: PythonEnvironment, + options: PackageManagementOptions, + toolToken?: CancellationToken, + scope?: Uri, + ): Promise { + throwIfCancelled(toolToken); let toInstall: string[] = [...(options.install ?? [])]; let toUninstall: string[] = [...(options.uninstall ?? [])]; @@ -102,6 +122,9 @@ export class PipPackageManager implements PackageManager, Disposable { const manageCommandOptions: CommandConstructorOptions = { pythonExecutable, log: this.log, + cwd: scope?.fsPath, + cancellationToken: token, + toolExecution: !!toolToken, }; if (toUninstall.length > 0) { @@ -114,6 +137,7 @@ export class PipPackageManager implements PackageManager, Disposable { await command.execute({ packages: parsePackageSpecs(toUninstall), cancellationToken: token, + toolExecution: !!toolToken, }); } @@ -128,6 +152,7 @@ export class PipPackageManager implements PackageManager, Disposable { packages: parsePackageSpecs(toInstall), upgrade: options.upgrade, cancellationToken: token, + toolExecution: !!toolToken, }); } @@ -138,7 +163,8 @@ export class PipPackageManager implements PackageManager, Disposable { (changes) => { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, - () => this.fetchPackages(environment, !options.runHeadless), + () => this.fetchPackages(environment, !options.runHeadless, toolToken), + !!toolToken, ); } catch (e) { if (e instanceof CancellationError) { @@ -159,7 +185,7 @@ export class PipPackageManager implements PackageManager, Disposable { }; if (options.runHeadless) { - await execute(); + await execute(toolToken); return; } @@ -206,8 +232,13 @@ export class PipPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } - private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise { + private async fetchPackages( + environment: PythonEnvironment, + showErrors = true, + token?: CancellationToken, + ): Promise { try { + throwIfCancelled(token); const pythonExecutable = environment.execInfo?.run?.executable; if (!pythonExecutable) { throw new Error('Unable to determine Python executable path'); @@ -216,17 +247,25 @@ export class PipPackageManager implements PackageManager, Disposable { { pythonExecutable, log: this.log, + cancellationToken: token, + toolExecution: !!token, }, environment.environmentPath.fsPath, PipListCommand, UvListCommand, ); - const data = await listCmd.execute(); + const data = await listCmd.execute( + token ? { cancellationToken: token, strict: true, toolExecution: true } : undefined, + ); + throwIfCancelled(token); const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); this.packages.set(environment.envId.id, packages); return packages; } catch (error) { this.log.error('Error refreshing packages', error); + if (token) { + throw error; + } if (showErrors) { setImmediate(async () => { await showErrorMessageWithLogs(l10n.t('Error refreshing packages'), this.log); @@ -254,56 +293,49 @@ export class PipPackageManager implements PackageManager, Disposable { } } - async getPackageAvailableVersions( + private async getPackageAvailableVersionsCommand( environment: PythonEnvironment, - packageName: string, - ): Promise { + ): Promise { const pythonExecutable = environment.execInfo?.run?.executable; if (!pythonExecutable) { throw new Error(`Python executable is unavailable for environment: ${environment.envId.id}`); } - const pythonVersion = PythonVersion.tryParse(environment.version); - if (!pythonVersion) { - throw new Error(`Python version is unavailable for environment: ${environment.envId.id}`); - } - const baseVersion = pythonVersion.toReleaseString(); - const availableVersions = await createPipOrUvCommandWithKind( { pythonExecutable, log: this.log }, environment.environmentPath.fsPath, PipAvailableVersionsCommand, UvAvailableVersionsCommand, ); + if (availableVersions.kind === 'uv') { + return availableVersions.command; + } - // For pip < 21.2.0, check version first. - if (availableVersions.kind === 'pip') { - const pipVersion = await new PipVersionCommand({ pythonExecutable, log: this.log }).execute(); - if (!pipVersion) { - throw new Error(`Unable to determine pip version for environment: ${environment.envId.id}`); - } - if (compare(pipVersion.public, '21.2.0') < 0) { - throw new PackageVersionLookupNotSupportedError( - `Package version lookup requires pip 21.2 or newer; the environment has pip ${pipVersion.public}.`, - ); - } - if (compare(pipVersion.public, '25.1') >= 0) { - const versions = await availableVersions.command.execute({ - packageName, - pythonVersion: baseVersion, - }); - return versions.sort((a, b) => compare(b.public, a.public)); - } - - const textCommand = new PipAvailableVersionsTextCommand({ pythonExecutable, log: this.log }); - const textVersions = await textCommand.execute({ packageName, pythonVersion: baseVersion }); - return textVersions.sort((a, b) => compare(b.public, a.public)); + const version = await new PipVersionCommand({ pythonExecutable, log: this.log }).execute(); + if (!version) { + throw new Error(`Unable to determine pip version for environment: ${environment.envId.id}`); + } + if (compare(version.public, '21.2.0') < 0) { + throw new PackageVersionLookupNotSupportedError( + `Package version lookup requires pip 21.2 or newer; the environment has pip ${version.public}.`, + ); + } + if (compare(version.public, '25.1') >= 0) { + return availableVersions.command; } - const versions = await availableVersions.command.execute({ - packageName, - pythonVersion: baseVersion, - }); + return new PipAvailableVersionsTextCommand({ pythonExecutable, log: this.log }); + } + + async getPackageAvailableVersions(environment: PythonEnvironment, packageName: string): Promise { + const baseVersion = environment.version + ? PythonVersion.tryParse(environment.version)?.toReleaseString() + : undefined; + if (!baseVersion) { + throw new Error(`Python version is unavailable for environment: ${environment.envId.id}`); + } + const command = await this.getPackageAvailableVersionsCommand(environment); + const versions = await command.execute({ packageName, pythonVersion: baseVersion }); return versions.sort((a, b) => compare(b.public, a.public)); } diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index a99a6a2ad..f6bdd8a7d 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -2,14 +2,24 @@ import * as tomljs from '@iarna/toml'; import { valid as pep440Valid } from '@renovatebot/pep440'; import * as fse from 'fs-extra'; import * as path from 'path'; -import { l10n, LogOutputChannel, ProgressLocation, QuickInputButtons, QuickPickItem, Uri, window } from 'vscode'; +import { + CancellationToken, + l10n, + LogOutputChannel, + ProgressLocation, + QuickInputButtons, + QuickPickItem, + Uri, + window, +} from 'vscode'; import { PackageManagementOptions, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; import { EXTENSION_ROOT_DIR } from '../../common/constants'; import { PackageManagement, Pickers, VenvManagerStrings } from '../../common/localize'; import { traceInfo } from '../../common/logging'; -import { normalizePath } from '../../common/utils/pathUtils'; +import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; import { showQuickPickWithButtons, withProgress } from '../../common/window.apis'; import { findFiles } from '../../common/workspace.apis'; +import { throwIfCancelled } from '../../internal/pythonToolSupport'; import { selectFromCommonPackagesToInstall, selectFromInstallableToInstall } from '../common/pickers'; import { normalizePackageName } from '../common/packageUtils'; import { Installable } from '../common/types'; @@ -69,11 +79,16 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { return undefined; } -async function tomlParse(fsPath: string, log?: LogOutputChannel): Promise { +async function tomlParse(fsPath: string, log?: LogOutputChannel, strict = false): Promise { try { const content = await fse.readFile(fsPath, 'utf-8'); return tomljs.parse(content); } catch (err) { + if (strict) { + throw new Error( + l10n.t('Could not read or parse {0}: {1}', fsPath, err instanceof Error ? err.message : String(err)), + ); + } log?.error('Failed to parse `pyproject.toml`:', err); } return {}; @@ -263,6 +278,8 @@ export interface ProjectInstallableOptions { * Creation root used to prefer the pyproject.toml that owns the environment. */ preferredRoot?: Uri; + token?: CancellationToken; + runHeadless?: boolean; } export async function getWorkspacePackagesToInstall( @@ -309,168 +326,179 @@ export async function getProjectInstallable( const installable: Installable[] = []; let validationError: { message: string; fileUri: Uri } | undefined; - await withProgress( - { - location: ProgressLocation.Notification, - title: VenvManagerStrings.searchingDependencies, - }, - async (_progress, token) => { - const results: Uri[] = ( - await Promise.all([ - findFiles('**/*requirements*.txt', exclude, undefined, token), - findFiles('*requirements*.txt', exclude, undefined, token), - findFiles('**/requirements/*.txt', exclude, undefined, token), - findFiles('**/pyproject.toml', exclude, undefined, token), - ]) - ).flat(); - - // Deduplicate by fsPath - const uniqueResults = Array.from(new Map(results.map((uri) => [uri.fsPath, uri])).values()); - - const fsPaths = projects.map((p) => p.uri.fsPath); - // Compute depth relative to the owning project root so ordering reflects - // "shallower within the project", independent of where the project lives on disk. - const depthFromProject = (uri: Uri): number => { - const projectRoot = api.getPythonProject(uri)?.uri.fsPath; - if (!projectRoot) { - return Number.MAX_SAFE_INTEGER; + const search = async (token?: CancellationToken) => { + throwIfCancelled(options?.token); + const results: Uri[] = ( + await Promise.all([ + findFiles('**/*requirements*.txt', exclude, undefined, token), + findFiles('*requirements*.txt', exclude, undefined, token), + findFiles('**/requirements/*.txt', exclude, undefined, token), + findFiles('**/pyproject.toml', exclude, undefined, token), + ]) + ).flat(); + + // Deduplicate by fsPath + const uniqueResults = Array.from(new Map(results.map((uri) => [uri.fsPath, uri])).values()); + + const fsPaths = projects.map((p) => p.uri.fsPath); + // Compute depth relative to the owning project root so ordering reflects + // "shallower within the project", independent of where the project lives on disk. + const depthFromProject = (uri: Uri): number => { + const projectRoot = api.getPythonProject(uri)?.uri.fsPath; + if (!projectRoot) { + return Number.MAX_SAFE_INTEGER; + } + const rel = path.relative(projectRoot, uri.fsPath); + if (!rel) { + return 0; + } + return rel.split(path.sep).filter((segment) => segment.length > 0).length; + }; + const filtered = uniqueResults + .filter((uri) => { + if ( + options?.runHeadless && + options.preferredRoot && + !isSameOrParentPath(options.preferredRoot.fsPath, uri.fsPath) + ) { + return false; } - const rel = path.relative(projectRoot, uri.fsPath); - if (!rel) { - return 0; + const p = api.getPythonProject(uri)?.uri.fsPath; + return p && fsPaths.includes(p); + }) + .sort((a, b) => { + // Sort by path depth relative to the project root (shallowest first) so + // top-level files like requirements.txt appear before deeply nested ones. + const depthA = depthFromProject(a); + const depthB = depthFromProject(b); + if (depthA !== depthB) { + return depthA - depthB; } - return rel.split(path.sep).filter((segment) => segment.length > 0).length; - }; - const filtered = uniqueResults - .filter((uri) => { - const p = api.getPythonProject(uri)?.uri.fsPath; - return p && fsPaths.includes(p); - }) - .sort((a, b) => { - // Sort by path depth relative to the project root (shallowest first) so - // top-level files like requirements.txt appear before deeply nested ones. - const depthA = depthFromProject(a); - const depthB = depthFromProject(b); - if (depthA !== depthB) { - return depthA - depthB; - } - const pathA = normalizePath(a.fsPath); - const pathB = normalizePath(b.fsPath); - return pathA < pathB ? -1 : pathA > pathB ? 1 : 0; - }); + const pathA = normalizePath(a.fsPath); + const pathB = normalizePath(b.fsPath); + return pathA < pathB ? -1 : pathA > pathB ? 1 : 0; + }); - // Parse all TOML files first (in parallel), keyed by fsPath, so the - // subsequent duplicate-detection pass can run sequentially and - // deterministically over the depth-sorted `filtered` list. - const parsedTomls = new Map(); - await Promise.all( - filtered.map(async (uri) => { - if (uri.fsPath.endsWith('.toml')) { - const toml = await tomlParse(uri.fsPath); - parsedTomls.set(uri.fsPath, toml); - } - }), - ); + // Parse all TOML files first (in parallel), keyed by fsPath, so the + // subsequent duplicate-detection pass can run sequentially and + // deterministically over the depth-sorted `filtered` list. + const parsedTomls = new Map(); + await Promise.all( + filtered.map(async (uri) => { + if (uri.fsPath.endsWith('.toml')) { + const toml = await tomlParse(uri.fsPath, undefined, options?.runHeadless); + parsedTomls.set(uri.fsPath, toml); + } + }), + ); - const selectedTomls = new Map(); - const ambiguousProjectNames = new Set(); - if (options?.deduplicateProjectPackages) { - const tomlsByProjectName = new Map(); - for (const uri of filtered) { - if (!uri.fsPath.endsWith('.toml')) { - continue; - } - const toml = parsedTomls.get(uri.fsPath) ?? {}; - const projectName = getTomlProjectName(toml); - if (!projectName || getTomlInstallable(toml, uri).length === 0) { - continue; - } - const normalizedName = normalizePackageName(projectName); - const candidates = tomlsByProjectName.get(normalizedName) ?? []; - candidates.push(uri); - tomlsByProjectName.set(normalizedName, candidates); + const selectedTomls = new Map(); + const ambiguousProjectNames = new Set(); + if (options?.deduplicateProjectPackages) { + const tomlsByProjectName = new Map(); + for (const uri of filtered) { + if (!uri.fsPath.endsWith('.toml')) { + continue; + } + const toml = parsedTomls.get(uri.fsPath) ?? {}; + const projectName = getTomlProjectName(toml); + if (!projectName || getTomlInstallable(toml, uri).length === 0) { + continue; } + const normalizedName = normalizePackageName(projectName); + const candidates = tomlsByProjectName.get(normalizedName) ?? []; + candidates.push(uri); + tomlsByProjectName.set(normalizedName, candidates); + } - tomlsByProjectName.forEach((candidates, projectName) => { - if (candidates.length === 1) { - selectedTomls.set(projectName, candidates[0]); - return; - } + tomlsByProjectName.forEach((candidates, projectName) => { + if (candidates.length === 1) { + selectedTomls.set(projectName, candidates[0]); + return; + } - const preferredRoot = options.preferredRoot?.fsPath; - const preferredCandidates = preferredRoot - ? candidates - .filter((candidate) => { - const projectDir = path.dirname(candidate.fsPath); - const relative = path.relative(projectDir, preferredRoot); - return ( - relative === '' || - (relative !== '..' && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative)) - ); - }) - .sort((a, b) => path.dirname(b.fsPath).length - path.dirname(a.fsPath).length) - : []; - - if (preferredCandidates.length > 0) { - selectedTomls.set(projectName, preferredCandidates[0]); - } else { - ambiguousProjectNames.add(projectName); + const preferredRoot = options.preferredRoot?.fsPath; + const preferredCandidates = preferredRoot + ? candidates + .filter((candidate) => { + const projectDir = path.dirname(candidate.fsPath); + const relative = path.relative(projectDir, preferredRoot); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); + }) + .sort((a, b) => path.dirname(b.fsPath).length - path.dirname(a.fsPath).length) + : []; + + if (preferredCandidates.length > 0) { + selectedTomls.set(projectName, preferredCandidates[0]); + } else { + ambiguousProjectNames.add(projectName); + traceInfo( + `Skipping ambiguous editable installs for package "${projectName}" because no ` + + 'candidate contains the environment creation root.', + ); + } + }); + } + + for (const uri of filtered) { + if (uri.fsPath.endsWith('.toml')) { + const toml = parsedTomls.get(uri.fsPath) ?? {}; + const tomlInstallables = getTomlInstallable(toml, uri); + + const projectName = getTomlProjectName(toml); + if (options?.deduplicateProjectPackages && projectName && tomlInstallables.length > 0) { + const normalizedName = normalizePackageName(projectName); + const selected = selectedTomls.get(normalizedName); + if (ambiguousProjectNames.has(normalizedName)) { + continue; + } + if (selected && selected.fsPath !== uri.fsPath) { traceInfo( - `Skipping ambiguous editable installs for package "${projectName}" because no ` + - 'candidate contains the environment creation root.', + `Skipping duplicate project package "${projectName}" from ${uri.fsPath}; it is ` + + `already provided by ${selected.fsPath}.`, ); + continue; } - }); - } - - for (const uri of filtered) { - if (uri.fsPath.endsWith('.toml')) { - const toml = parsedTomls.get(uri.fsPath) ?? {}; - const tomlInstallables = getTomlInstallable(toml, uri); - - const projectName = getTomlProjectName(toml); - if (options?.deduplicateProjectPackages && projectName && tomlInstallables.length > 0) { - const normalizedName = normalizePackageName(projectName); - const selected = selectedTomls.get(normalizedName); - if (ambiguousProjectNames.has(normalizedName)) { - continue; - } - if (selected && selected.fsPath !== uri.fsPath) { - traceInfo( - `Skipping duplicate project package "${projectName}" from ${uri.fsPath}; it is ` + - `already provided by ${selected.fsPath}.`, - ); - continue; - } - } + } - // Validate pyproject.toml (report only the first error found). - if (!validationError) { - const error = validatePyprojectToml(toml); - if (error) { - validationError = { - message: error, - fileUri: uri, - }; - } + // Validate pyproject.toml (report only the first error found). + if (!validationError) { + const error = validatePyprojectToml(toml); + if (error) { + validationError = { + message: error, + fileUri: uri, + }; } - - installable.push(...tomlInstallables); - } else { - const name = path.basename(uri.fsPath); - installable.push({ - name, - uri, - displayName: name, - group: 'Requirements', - args: ['-r', uri.fsPath], - }); } + + installable.push(...tomlInstallables); + } else { + const name = path.basename(uri.fsPath); + installable.push({ + name, + uri, + displayName: name, + group: 'Requirements', + args: ['-r', uri.fsPath], + }); } - }, - ); + } + throwIfCancelled(options?.token); + }; + if (options?.runHeadless) { + await search(options.token); + } else { + await withProgress( + { location: ProgressLocation.Notification, title: VenvManagerStrings.searchingDependencies }, + (_progress, token) => search(token), + ); + } return { installables: installable, diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 2b031ba3b..25284d264 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { EventEmitter, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri, window } from 'vscode'; +import { EventEmitter, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -20,9 +20,10 @@ import { import { SysManagerStrings } from '../../common/localize'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; +import { EnvironmentToolSupport, pythonToolSupport } from '../../internal/pythonToolSupport'; +import { withProgress } from '../../common/window.apis'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; -import { getLatest } from '../common/utils'; import { clearSystemEnvCache, getSystemEnvForGlobal, @@ -31,7 +32,7 @@ import { setSystemEnvForWorkspace, setSystemEnvForWorkspaces, } from './cache'; -import { refreshPythons, resolveSystemPythonEnvironmentPath } from './utils'; +import { getDefaultGlobalPython, refreshPythons, resolveSystemPythonEnvironmentPath } from './utils'; import { installPythonWithUv, promptInstallPythonViaUv, selectPythonVersionToInstall } from './uvPythonInstaller'; export class SysPythonManager implements EnvironmentManager { @@ -66,17 +67,27 @@ export class SysPythonManager implements EnvironmentManager { } private _initialized: Deferred | undefined; + private discovery: Deferred | undefined; + private initializationError: unknown; + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => { + await this.initializeDiscovery(); + if (this.initializationError) { + throw this.initializationError; + } + }, + get: (scope) => this.get(scope, true), + getEnvironments: (scope) => this.getEnvironments(scope, true), + resolve: (scope) => this.resolve(scope), + }; + async initialize(): Promise { if (this._initialized) { return this._initialized.promise; } - this._initialized = createDeferred(); - try { - await this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering); - - // If no Python environments were found, offer to install via uv + await this.initializeDiscovery(); if (this.collection.length === 0) { const pythonPath = await promptInstallPythonViaUv('activation', this.log); if (pythonPath) { @@ -101,12 +112,28 @@ export class SysPythonManager implements EnvironmentManager { } } + private async initializeDiscovery(): Promise { + if (this.discovery) { + return this.discovery.promise; + } + const discovery = (this.discovery = createDeferred()); + try { + await this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering); + } catch (error) { + this.initializationError = error; + this.discovery = undefined; + throw error; + } finally { + discovery.resolve(); + } + } + refresh(_scope: RefreshEnvironmentsScope): Promise { return this.internalRefresh(true, SysManagerStrings.sysManagerRefreshing); } private async internalRefresh(hardRefresh: boolean, title: string) { - await window.withProgress( + await withProgress( { location: ProgressLocation.Window, title, @@ -125,12 +152,13 @@ export class SysPythonManager implements EnvironmentManager { ]; this._onDidChangeEnvironments.fire(args); + this.initializationError = undefined; }, ); } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - await this.initialize(); + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === 'all' || scope === 'global') { return Array.from(this.collection); @@ -146,8 +174,8 @@ export class SysPythonManager implements EnvironmentManager { return []; } - async get(scope: GetEnvironmentScope): Promise { - const fastResult = await tryFastPathGet({ + async get(scope: GetEnvironmentScope, toolExecution = false): Promise { + const fastResult = toolExecution ? undefined : await tryFastPathGet({ initialized: this._initialized, setInitialized: (deferred) => { this._initialized = deferred; @@ -158,13 +186,13 @@ export class SysPythonManager implements EnvironmentManager { getPersistedPath: (fsPath) => getSystemEnvForWorkspace(fsPath), getGlobalPersistedPath: () => getSystemEnvForGlobal(), resolve: (p) => resolveSystemPythonEnvironmentPath(p, this.nativeFinder, this.api, this), - startBackgroundInit: () => this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering), + startBackgroundInit: () => this.initializeDiscovery(), }); if (fastResult) { return fastResult.env; } - await this.initialize(); + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope instanceof Uri) { return this.fromEnvMap(scope) ?? this.globalEnv; @@ -175,7 +203,7 @@ export class SysPythonManager implements EnvironmentManager { async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { if (scope === undefined) { - this.globalEnv = environment ?? getLatest(this.collection); + this.globalEnv = environment ?? getDefaultGlobalPython(this.collection); if (environment) { await setSystemEnvForGlobal(environment.environmentPath.fsPath); } @@ -282,7 +310,7 @@ export class SysPythonManager implements EnvironmentManager { _options?: CreateEnvironmentOptions, ): Promise { // Show QuickPick to select Python version - const selectedVersion = await selectPythonVersionToInstall(); + const selectedVersion = await selectPythonVersionToInstall(this.log); if (!selectedVersion) { // User cancelled return undefined; @@ -294,12 +322,14 @@ export class SysPythonManager implements EnvironmentManager { // Resolve the installed Python using NativePythonFinder instead of full refresh const resolved = await resolveSystemPythonEnvironmentPath(pythonPath, this.nativeFinder, this.api, this); if (resolved) { - // Add to collection, update global env, and fire change event - this.collection.push(resolved); - this.globalEnv = resolved; - await setSystemEnvForGlobal(resolved.environmentPath.fsPath); - this._onDidChangeEnvironments.fire([{ environment: resolved, kind: EnvironmentChangeKind.add }]); - return resolved; + // Reinstalling an already-installed version resolves to an interpreter that is already listed. + const environment = this.trackResolvedEnvironment(resolved); + this.globalEnv = environment; + await setSystemEnvForGlobal(environment.environmentPath.fsPath); + if (environment === resolved) { + this._onDidChangeEnvironments.fire([{ environment, kind: EnvironmentChangeKind.add }]); + } + return environment; } } @@ -362,18 +392,14 @@ export class SysPythonManager implements EnvironmentManager { // If the environment is not found, resolve the fsPath. if (!this.globalEnv) { - this.globalEnv = await resolveSystemPythonEnvironmentPath(fsPath, this.nativeFinder, this.api, this); - - // If the environment is resolved, add it to the collection - if (this.globalEnv) { - this.collection.push(this.globalEnv); - } + const resolved = await resolveSystemPythonEnvironmentPath(fsPath, this.nativeFinder, this.api, this); + this.globalEnv = resolved ? this.trackResolvedEnvironment(resolved) : undefined; } } // If a global environment is still not set, try using the latest environment if (!this.globalEnv) { - this.globalEnv = getLatest(this.collection); + this.globalEnv = getDefaultGlobalPython(this.collection); } // Try to find workspace environments @@ -395,9 +421,7 @@ export class SysPythonManager implements EnvironmentManager { const resolved = await resolveSystemPythonEnvironmentPath(env, this.nativeFinder, this.api, this); if (resolved) { - // If resolved add it to the collection. - this.fsPathToEnv.set(normalizedPath, resolved); - this.collection.push(resolved); + this.fsPathToEnv.set(normalizedPath, this.trackResolvedEnvironment(resolved)); } else { this.log.error(`Failed to resolve python environment: ${env}`); } @@ -405,4 +429,20 @@ export class SysPythonManager implements EnvironmentManager { } } } + + /** + * Returns the collection entry for a resolved environment, adding it only when its interpreter is not + * already listed. A saved path can be an alias of a discovered interpreter (for example a symlink or + * uv's `~/.local/bin` launchers), which resolves to an environment the collection already contains. + * The result is `resolved` itself only when it was added. + */ + private trackResolvedEnvironment(resolved: PythonEnvironment): PythonEnvironment { + const key = normalizePath(resolved.environmentPath.fsPath); + const existing = this.collection.find((e) => normalizePath(e.environmentPath.fsPath) === key); + if (existing) { + return existing; + } + this.collection.push(resolved); + return resolved; + } } diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index a695462bb..16058f40f 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs-extra'; +import * as path from 'path'; import { LogOutputChannel, QuickPickItem, Uri, window } from 'vscode'; import { EnvironmentManager, Package, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; import { getExtension } from '../../common/extension.apis'; @@ -12,12 +14,15 @@ import { NativePythonEnvironmentKind, NativePythonFinder, } from '../common/nativePythonFinder'; -import { shortenVersionString, sortEnvironments } from '../common/utils'; +import { getLatest, shortenVersionString, sortEnvironments } from '../common/utils'; const PIXI_EXTENSION_ID = 'renan-r-santos.pixi-code'; const PIXI_RECOMMEND_DONT_ASK_KEY = 'pixi-extension-recommend-dont-ask'; let pixiRecommendationShown = false; +// Global environments created for uv-managed Python installations. +const uvManagedPythons = new WeakSet(); + function asPackageQuickPickItem(name: string, version?: string): QuickPickItem { return { label: name, @@ -66,9 +71,9 @@ function getKindName(kind: NativePythonEnvironmentKind | undefined): string | un } } -function getPythonInfo(env: NativeEnvInfo): PythonEnvironmentInfo { +function getPythonInfo(env: NativeEnvInfo, uvManaged: boolean = false): PythonEnvironmentInfo { if (env.executable && env.version && env.prefix) { - const kindName = getKindName(env.kind); + const kindName = uvManaged ? 'uv' : getKindName(env.kind); const sv = shortenVersionString(env.version); const name = kindName ? `Python ${sv} (${kindName})` : `Python ${sv}`; const displayName = kindName ? `Python ${sv} (${kindName})` : `Python ${sv}`; @@ -127,6 +132,36 @@ async function recommendPixiExtension(): Promise { } } +/** + * Returns true when PET reports a Python installation managed by uv (`uv python install`). + * + * PET reports both uv-created virtual environments and uv-managed Python installations with the + * `Uv` kind. Only virtual environments have a `pyvenv.cfg` in their prefix, so its absence + * identifies a base interpreter that belongs with the global Python installations. + * + * @param env Environment information reported by PET. + * @returns `true` for a uv-managed base interpreter, otherwise `false`. + */ +export async function isUvManagedPythonInstall(env: NativeEnvInfo): Promise { + if (env.kind !== NativePythonEnvironmentKind.venvUv || !env.prefix) { + return false; + } + return !(await fs.pathExists(path.join(env.prefix, 'pyvenv.cfg'))); +} + +/** + * Returns the global Python to use by default: the newest one, preferring installations that are not + * managed by uv. uv marks its installations EXTERNALLY-MANAGED (PEP 668), so installing packages into + * them fails; they are used by default only when no other Python is available. + * + * @param globals Global Python environments. + * @returns The default global Python, or `undefined` when there is none. + */ +export function getDefaultGlobalPython(globals: PythonEnvironment[]): PythonEnvironment | undefined { + const preferred = globals.filter((e) => !e.error && !uvManagedPythons.has(e)); + return getLatest(preferred.length > 0 ? preferred : globals); +} + export async function refreshPythons( hardRefresh: boolean, nativeFinder: NativePythonFinder, @@ -144,26 +179,34 @@ export async function refreshPythons( recommendPixiExtension().catch((e) => log.error('Error recommending Pixi extension', e)); } - const envs = allNativeEnvs.filter( - (e) => + const envs: { env: NativeEnvInfo; uvManaged: boolean }[] = []; + for (const e of allNativeEnvs) { + const uvManaged = e.kind === NativePythonEnvironmentKind.venvUv && (await isUvManagedPythonInstall(e)); + if ( + uvManaged || e.kind === undefined || - (e.kind && - [ - NativePythonEnvironmentKind.globalPaths, - NativePythonEnvironmentKind.homebrew, - NativePythonEnvironmentKind.linuxGlobal, - NativePythonEnvironmentKind.macCommandLineTools, - NativePythonEnvironmentKind.macPythonOrg, - NativePythonEnvironmentKind.macXCode, - NativePythonEnvironmentKind.windowsRegistry, - NativePythonEnvironmentKind.windowsStore, - NativePythonEnvironmentKind.winpython, - ].includes(e.kind)), - ); - envs.forEach((env) => { + [ + NativePythonEnvironmentKind.globalPaths, + NativePythonEnvironmentKind.homebrew, + NativePythonEnvironmentKind.linuxGlobal, + NativePythonEnvironmentKind.macCommandLineTools, + NativePythonEnvironmentKind.macPythonOrg, + NativePythonEnvironmentKind.macXCode, + NativePythonEnvironmentKind.windowsRegistry, + NativePythonEnvironmentKind.windowsStore, + NativePythonEnvironmentKind.winpython, + ].includes(e.kind) + ) { + envs.push({ env: e, uvManaged }); + } + } + envs.forEach(({ env, uvManaged }) => { try { - const envInfo = getPythonInfo(env); + const envInfo = getPythonInfo(env, uvManaged); const python = api.createPythonEnvironmentItem(envInfo, manager); + if (uvManaged) { + uvManagedPythons.add(python); + } collection.push(python); } catch (e) { log.error((e as Error).message); @@ -222,8 +265,12 @@ export async function resolveSystemPythonEnvironmentPath( // This is supposed to handle a python interpreter as long as we know some basic things about it if (resolved.executable && resolved.version && resolved.prefix) { - const envInfo = getPythonInfo(resolved); - return api.createPythonEnvironmentItem(envInfo, manager); + const uvManaged = await isUvManagedPythonInstall(resolved); + const python = api.createPythonEnvironmentItem(getPythonInfo(resolved, uvManaged), manager); + if (uvManaged) { + uvManagedPythons.add(python); + } + return python; } } catch (ex) { traceVerbose(`Failed to resolve env "${fsPath}": ${ex}`); diff --git a/src/managers/builtin/uvPythonInstaller.ts b/src/managers/builtin/uvPythonInstaller.ts index bf9654ef0..0d24cc4b0 100644 --- a/src/managers/builtin/uvPythonInstaller.ts +++ b/src/managers/builtin/uvPythonInstaller.ts @@ -1,3 +1,5 @@ +import * as os from 'os'; +import * as path from 'path'; import { LogOutputChannel, ProgressLocation, @@ -9,6 +11,8 @@ import { TaskScope, } from 'vscode'; import { spawnProcess } from '../../common/childProcess.apis'; +import { launchBrowser } from '../../common/env.apis'; +import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { Common, UvInstallStrings } from '../../common/localize'; import { traceError, traceInfo, traceLog, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; @@ -17,22 +21,24 @@ import { executeTask, onDidEndTaskProcess } from '../../common/tasks.apis'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred } from '../../common/utils/deferred'; +import { normalizePath } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { showErrorMessage, showInformationMessage, showQuickPick, withProgress } from '../../common/window.apis'; -import { isUvInstalled, resetUvInstallationCache } from './helpers'; +import { getUvExecutable, isUvInstalled, setUvExecutable } from './helpers'; export const UV_INSTALL_PYTHON_DONT_ASK_KEY = 'python-envs:uv:UV_INSTALL_PYTHON_DONT_ASK'; const MAX_PROMPT_DETAIL_LENGTH = 120; const TASK_TIMEOUT_MS = 5 * 60 * 1000; +const PYTHON_INSTALLATION_OPTIONS_URL = + 'https://code.visualstudio.com/docs/python/python-tutorial#_install-a-python-interpreter'; // Accept only numeric release segments before forwarding script-controlled input to uv. const INSTALLABLE_PYTHON_VERSION = /^\d+(?:\.\d+)*(?:(?:a|b|rc)\d+)?(?:\.dev\d+)?$/i; // Remove C0/C1 controls and Unicode zero-width/bidirectional formatting characters // before displaying script-controlled text in a modal prompt. -const PROMPT_CONTROL_CHARACTERS = - /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g; +const PROMPT_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g; export type UvPythonInstallTrigger = 'activation' | 'createEnvironment' | 'inlineScript'; @@ -175,6 +181,31 @@ async function runTaskAndWait(name: string, executable: string, args: string[]): } } +/** + * Returns the executable destination used by uv's standalone installers. + * Honors their explicit overrides, legacy Cargo layout, and XDG defaults. + */ +function getInstalledUvExecutable(): string { + const home = os.homedir(); + const forced = + process.env.UV_INSTALL_DIR || process.env.CARGO_DIST_FORCE_INSTALL_DIR || process.env.UV_UNMANAGED_INSTALL; + let directory: string; + if (forced) { + const cargoHome = process.env.CARGO_HOME || path.join(home, '.cargo'); + directory = + normalizePath(path.resolve(forced)) === normalizePath(path.resolve(cargoHome)) + ? path.join(forced, 'bin') + : forced; + } else { + directory = + process.env.XDG_BIN_HOME || + (process.env.XDG_DATA_HOME + ? path.join(process.env.XDG_DATA_HOME, '..', 'bin') + : path.join(home, '.local', 'bin')); + } + return path.resolve(directory, isWindows() ? 'uv.exe' : 'uv'); +} + /** * Installs uv using the platform-appropriate method. * @param log Optional log output channel @@ -187,8 +218,8 @@ export async function installUv(_log?: LogOutputChannel): Promise { const success = await runTaskAndWait(UvInstallStrings.installingUv, executable, args); if (success) { - // Reset the cache so isUvInstalled() will re-check - resetUvInstallationCache(); + // Shell-profile updates cannot change the running extension host's PATH. + setUvExecutable(getInstalledUvExecutable()); traceInfo('uv installed successfully'); } else { traceError('Failed to install uv'); @@ -197,26 +228,28 @@ export async function installUv(_log?: LogOutputChannel): Promise { return success; } -export async function ensureUvForInlineScriptVersionLookupDetailed( - requiresPython: string, +async function ensureUvForVersionLookup( + prompt: string, log?: LogOutputChannel, ): Promise { if (await isUvInstalled(log)) { return 'available'; } - const displayedRequirement = sanitizePromptDetail(requiresPython); - if (!displayedRequirement) { - return 'failed'; - } - const selection = await showInformationMessage( - UvInstallStrings.inlineScriptInstallUvForVersionLookupPrompt(displayedRequirement), - { modal: true }, - UvInstallStrings.installUv, - ); + const selection = await showInformationMessage(prompt, { modal: true }, UvInstallStrings.installUv); if (selection !== UvInstallStrings.installUv) { return 'declined'; } + return installUvForVersionLookup(log); +} + +async function installUvForVersionLookup( + log?: LogOutputChannel, + showInstallFailure = false, +): Promise { if (!(await installUv(log))) { + if (showInstallFailure) { + await showErrorMessageWithLogs(UvInstallStrings.uvInstallFailed, log); + } return 'failed'; } if (await isUvInstalled(log)) { @@ -226,6 +259,47 @@ export async function ensureUvForInlineScriptVersionLookupDetailed( return 'failed'; } +/** + * Ensures uv is available before listing installable Python versions. + * + * @param log Optional log output channel. + * @returns Whether uv is available for version lookup. + */ +export async function ensureUvForPythonVersionLookup(log?: LogOutputChannel): Promise { + if (await isUvInstalled(log)) { + return true; + } + + const selection = await showInformationMessage( + UvInstallStrings.installUvForVersionLookupPrompt, + { modal: true }, + UvInstallStrings.installUv, + UvInstallStrings.otherPythonInstallationOptions, + ); + if (selection === UvInstallStrings.otherPythonInstallationOptions) { + await launchBrowser(PYTHON_INSTALLATION_OPTIONS_URL); + return false; + } + if (selection !== UvInstallStrings.installUv) { + return false; + } + return (await installUvForVersionLookup(log, true)) === 'available'; +} + +export async function ensureUvForInlineScriptVersionLookupDetailed( + requiresPython: string, + log?: LogOutputChannel, +): Promise { + const displayedRequirement = sanitizePromptDetail(requiresPython); + if (!displayedRequirement) { + return 'failed'; + } + return ensureUvForVersionLookup( + UvInstallStrings.inlineScriptInstallUvForVersionLookupPrompt(displayedRequirement), + log, + ); +} + export async function ensureUvForInlineScriptVersionLookup( requiresPython: string, log?: LogOutputChannel, @@ -240,11 +314,16 @@ export async function ensureUvForInlineScriptVersionLookup( * @returns Promise that resolves to the Python path, or undefined if not found */ export async function getUvPythonPath(version?: string): Promise { + const executable = await getUvExecutable(); + if (!executable) { + traceError('Cannot list installed Python versions: uv is unavailable'); + return undefined; + } return new Promise((resolve) => { const chunks: string[] = []; // Use --only-installed --managed-python to find only uv-managed Pythons const args = ['python', 'list', '--only-installed', '--managed-python', '--output-format', 'json']; - const proc = spawnProcess('uv', args); + const proc = spawnProcess(executable, args); proc.stdout?.on('data', (data) => chunks.push(data.toString())); proc.on('error', () => resolve(undefined)); proc.on('exit', (code) => { @@ -273,8 +352,8 @@ export async function getUvPythonPath(version?: string): Promise v.path); resolve(installed?.path ?? undefined); } - } catch { - traceError('Failed to parse uv python list output'); + } catch (error) { + traceError('Failed to parse uv python list output:', error); resolve(undefined); } } else { @@ -292,6 +371,11 @@ export async function getUvPythonPath(version?: string): Promise { + const executable = await getUvExecutable(); + if (!executable) { + traceError('Cannot list available Python versions: uv is unavailable'); + return []; + } return new Promise((resolve) => { const chunks: string[] = []; const args = ['python', 'list']; @@ -299,19 +383,23 @@ export async function getAvailablePythonVersions( args.push('--all-versions'); } args.push('--output-format', 'json'); - const proc = spawnProcess('uv', args); + const proc = spawnProcess(executable, args); proc.stdout?.on('data', (data) => chunks.push(data.toString())); - proc.on('error', () => resolve([])); + proc.on('error', (error) => { + traceError('Failed to run uv python list:', error); + resolve([]); + }); proc.on('exit', (code) => { if (code === 0 && chunks.length > 0) { try { const versions = JSON.parse(chunks.join('')) as UvPythonVersion[]; resolve(versions); - } catch { - traceError('Failed to parse uv python list output'); + } catch (error) { + traceError('Failed to parse uv python list output:', error); resolve([]); } } else { + traceError(`uv python list exited with code ${code}`); resolve([]); } }); @@ -325,9 +413,14 @@ interface PythonVersionQuickPickItem extends QuickPickItem { /** * Shows a QuickPick to select a Python version to install. + * @param log Optional log output channel. * @returns Promise that resolves to the selected version string, or undefined if cancelled */ -export async function selectPythonVersionToInstall(): Promise { +export async function selectPythonVersionToInstall(log?: LogOutputChannel): Promise { + if (!(await ensureUvForPythonVersionLookup(log))) { + return undefined; + } + const versions = await withProgress( { location: ProgressLocation.Notification, @@ -388,15 +481,20 @@ export async function selectPythonVersionToInstall(): Promise { +export async function installPythonViaUv(log?: LogOutputChannel, version?: string): Promise { + const executable = await getUvExecutable(log); + if (!executable) { + traceError('Cannot install Python: uv is unavailable'); + return false; + } const args = ['python', 'install']; if (version) { args.push(version); } - traceInfo(`Installing Python via uv: uv ${args.join(' ')}`); + traceInfo(`Installing Python via uv: ${executable} ${args.join(' ')}`); - const success = await runTaskAndWait(UvInstallStrings.installingPython, 'uv', args); + const success = await runTaskAndWait(UvInstallStrings.installingPython, executable, args); if (success) { traceInfo('Python installed successfully via uv'); diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 3d7859629..373adbe84 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -1,6 +1,15 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; +import { + CancellationToken, + EventEmitter, + l10n, + LogOutputChannel, + MarkdownString, + ProgressLocation, + ThemeIcon, + Uri, +} from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -27,11 +36,20 @@ import { traceError, traceWarn } from '../../common/logging'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { PythonVersion } from '../../common/pythonVersion'; +import { + EnvironmentToolSupport, + PythonToolError, + PythonToolOperation, + pythonToolSupport, + supportsEnvironmentTools, + throwIfCancelled, +} from '../../internal/pythonToolSupport'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import { findParentIfFile } from '../../features/envCommands'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, shortenVersionString, sortEnvironments } from '../common/utils'; +import { getDefaultGlobalPython } from './utils'; import { promptInstallPythonViaUv } from './uvPythonInstaller'; import { clearVenvCache, @@ -52,7 +70,9 @@ import { export class VenvManager implements EnvironmentManager { private collection: PythonEnvironment[] = []; + private environmentFolders: { collection: PythonEnvironment[]; length: number; folders: Set } | undefined; private readonly fsPathToEnv: Map = new Map(); + private readonly projectSelectionRevisions = new Map(); private globalEnv: PythonEnvironment | undefined; private skipWatcherRefresh = false; @@ -86,17 +106,136 @@ export class VenvManager implements EnvironmentManager { } private _initialized: Deferred | undefined; + private discovery: Deferred | undefined; + private initializationError: unknown; + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => { + if (!supportsEnvironmentTools(this.baseManager)) { + throw new PythonToolError( + 'UNSUPPORTED_MANAGER', + l10n.t('The base Python manager does not support non-interactive discovery.'), + ); + } + await this.baseManager[pythonToolSupport].initialize(); + await this.initializeDiscovery(); + if (this.initializationError) { + throw this.initializationError; + } + }, + get: (scope) => this.get(scope, true), + getEnvironments: (scope) => this.getEnvironments(scope, true), + resolve: (scope) => this.resolve(scope), + create: (scope, operation) => this.createForTools(scope, operation), + set: (scope, environment, token) => this.set(scope, environment, token), + }; + async initialize(): Promise { if (this._initialized) { return this._initialized.promise; } + const initialized = (this._initialized = createDeferred()); + try { + await this.initializeWithBase(); + } catch (error) { + if (this._initialized === initialized) { + this._initialized = undefined; + } + throw error; + } finally { + initialized.resolve(); + } + } - this._initialized = createDeferred(); + private async initializeWithBase(): Promise { + await this.initializeDiscovery(); + const discovery = this.discovery; + try { + await this.loadGlobalEnv(await this.baseManager.getEnvironments('global')); + } catch (error) { + // Discovery alone is not a complete human initialization when the base step fails. + if (this.discovery === discovery) { + this.discovery = undefined; + } + throw error; + } + } + private async initializeDiscovery(): Promise { + if (this.discovery) { + return this.discovery.promise; + } + const discovery = (this.discovery = createDeferred()); try { - await this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize); + await this.internalRefresh( + undefined, + false, + VenvManagerStrings.venvInitialize, + ProgressLocation.Window, + true, + ); + } catch (error) { + this.initializationError = error; + this.discovery = undefined; + throw error; + } finally { + discovery.resolve(); + } + } + + private async createForTools(scope: Uri, operation: PythonToolOperation): Promise { + const base = + operation.baseEnvironment ?? + getLatest( + (await operation.getGlobalEnvironments()).filter( + (environment) => PythonVersion.tryParse(environment.version)?.major === 3, + ), + ); + if (!base || PythonVersion.tryParse(base.version)?.major !== 3) { + throw new PythonToolError( + 'MISSING_PYTHON', + l10n.t('Install or select Python 3 before creating a virtual environment.'), + ); + } + throwIfCancelled(operation.token); + this.skipWatcherRefresh = true; + try { + const result = await quickCreateVenv( + this.nativeFinder, + this.api, + this.log, + this, + base, + scope, + undefined, + operation, + ); + if (!result?.environment) { + throw new PythonToolError( + 'CREATION_FAILED', + result?.envCreationErr ?? l10n.t('Virtual environment creation returned no environment.'), + ); + } + this.addEnvironment(result.environment, true); + try { + await fs.writeFile(path.join(result.environment.sysPrefix, '.gitignore'), '*\n', { flag: 'w' }); + } catch (error) { + throw new PythonToolError( + 'CREATION_FAILED', + l10n.t( + 'The environment was created, but writing its .gitignore failed: {0}', + error instanceof Error ? error.message : String(error), + ), + result.environment, + ); + } + return result.environment; + } catch (error) { + if (error instanceof PythonToolError && error.environment) { + this.addEnvironment(error.environment, true); + } + throw error; } finally { - this._initialized.resolve(); + this.skipWatcherRefresh = false; } } @@ -295,7 +434,7 @@ export class VenvManager implements EnvironmentManager { const changed: Uri[] = []; this.fsPathToEnv.forEach((env, uri) => { if (normalizePath(env.environmentPath.fsPath) === envPath) { - this.fsPathToEnv.delete(uri); + this.setProjectSelection(uri, undefined); changed.push(Uri.file(uri)); } }); @@ -313,12 +452,39 @@ export class VenvManager implements EnvironmentManager { return this.internalRefresh(undefined, true, VenvManagerStrings.venvRefreshing); } + /** + * Returns true when a known virtual environment is at, or inside, the given path. + * + * Called for every deletion in the workspace, so it is a single set lookup: the set holds every + * environment prefix and its parent folders, and is rebuilt when the collection changes. + * + * @param fsPath A file system path, for example of a deleted folder. + */ + hasEnvironmentAt(fsPath: string): boolean { + const cache = this.environmentFolders; + if (cache?.collection !== this.collection || cache.length !== this.collection.length) { + const folders = new Set(); + for (const env of this.collection) { + let folder = env.sysPrefix ? path.resolve(env.sysPrefix) : undefined; + while (folder && !folders.has(normalizePath(folder))) { + folders.add(normalizePath(folder)); + const parent = path.dirname(folder); + folder = parent !== folder ? parent : undefined; + } + } + this.environmentFolders = { collection: this.collection, length: this.collection.length, folders }; + } + return this.environmentFolders!.folders.has(normalizePath(path.resolve(fsPath))); + } + private async internalRefresh( scope: RefreshEnvironmentsScope, hardRefresh: boolean, title: string, location: ProgressLocation = ProgressLocation.Window, + toolExecution = false, ): Promise { + const selectionRevisions = new Map(this.projectSelectionRevisions); await withProgress( { location, @@ -339,16 +505,17 @@ export class VenvManager implements EnvironmentManager { this, scope ? [scope] : undefined, )) ?? []; - await this.loadEnvMap(); + await this.loadEnvMap(selectionRevisions, toolExecution); const added = this.collection.map((env) => ({ environment: env, kind: EnvironmentChangeKind.add })); this._onDidChangeEnvironments.fire([...discard, ...added]); + this.initializationError = undefined; }, ); } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - await this.initialize(); + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === 'all') { return Array.from(this.collection); @@ -361,8 +528,8 @@ export class VenvManager implements EnvironmentManager { return env ? [env] : []; } - async get(scope: GetEnvironmentScope): Promise { - const fastResult = await tryFastPathGet({ + async get(scope: GetEnvironmentScope, toolExecution = false): Promise { + const fastResult = toolExecution ? undefined : await tryFastPathGet({ initialized: this._initialized, setInitialized: (deferred) => { this._initialized = deferred; @@ -372,13 +539,13 @@ export class VenvManager implements EnvironmentManager { getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), getPersistedPath: (fsPath) => getVenvForWorkspace(fsPath), resolve: (p) => resolveVenvPythonEnvironmentPath(p, this.nativeFinder, this.api, this, this.baseManager), - startBackgroundInit: () => this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize), + startBackgroundInit: () => this.initializeWithBase(), }); if (fastResult) { return fastResult.env; } - await this.initialize(); + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (!scope) { // `undefined` for venv scenario return the global environment. @@ -398,7 +565,8 @@ export class VenvManager implements EnvironmentManager { return env ?? this.globalEnv; } - async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { + async set(scope: SetEnvironmentScope, environment?: PythonEnvironment, token?: CancellationToken): Promise { + throwIfCancelled(token); if (scope === undefined) { const before = this.globalEnv; this.globalEnv = environment; @@ -417,7 +585,7 @@ export class VenvManager implements EnvironmentManager { } // Notify user if VIRTUAL_ENV is set and they're trying to select a different environment - if (process.env.VIRTUAL_ENV && environment) { + if (!token && process.env.VIRTUAL_ENV && environment) { const virtualEnvPath = process.env.VIRTUAL_ENV; const selectedPath = environment.sysPrefix; // Only show notification if they selected a different environment @@ -426,13 +594,7 @@ export class VenvManager implements EnvironmentManager { } } - const normalizedPwPath = normalizePath(pw.uri.fsPath); - const before = this.fsPathToEnv.get(normalizedPwPath); - if (environment) { - this.fsPathToEnv.set(normalizedPwPath, environment); - } else { - this.fsPathToEnv.delete(normalizedPwPath); - } + const before = this.setProjectSelection(pw.uri.fsPath, environment); await setVenvForWorkspace(pw.uri.fsPath, environment?.environmentPath.fsPath); if (before?.envId.id !== environment?.envId.id) { @@ -452,13 +614,7 @@ export class VenvManager implements EnvironmentManager { const before: Map = new Map(); projects.forEach((p) => { - const normalizedPath = normalizePath(p.uri.fsPath); - before.set(p.uri.fsPath, this.fsPathToEnv.get(normalizedPath)); - if (environment) { - this.fsPathToEnv.set(normalizedPath, environment); - } else { - this.fsPathToEnv.delete(normalizedPath); - } + before.set(p.uri.fsPath, this.setProjectSelection(p.uri.fsPath, environment)); }); await setVenvForWorkspaces( @@ -475,6 +631,21 @@ export class VenvManager implements EnvironmentManager { } } + private setProjectSelection( + fsPath: string, + environment: PythonEnvironment | undefined, + ): PythonEnvironment | undefined { + const key = normalizePath(fsPath); + const before = this.fsPathToEnv.get(key); + this.projectSelectionRevisions.set(key, (this.projectSelectionRevisions.get(key) ?? 0) + 1); + if (environment) { + this.fsPathToEnv.set(key, environment); + } else { + this.fsPathToEnv.delete(key); + } + return before; + } + async resolve(context: ResolveEnvironmentContext): Promise { if (context instanceof Uri) { // NOTE: `environmentPath` for envs in `this.collection` for venv always points to the python @@ -497,6 +668,7 @@ export class VenvManager implements EnvironmentManager { if (resolved.envId.managerId === `${PYTHON_EXTENSION_ID}:venv`) { // We should only return the resolved env if it is a venv. // Fall through an return undefined if it is not a venv + this.addEnvironment(resolved, true); return resolved; } } @@ -568,18 +740,28 @@ export class VenvManager implements EnvironmentManager { // If a global environment is still not set, use latest from globals if (!this.globalEnv) { - this.globalEnv = getLatest(globals); + this.globalEnv = getDefaultGlobalPython(globals); } } /** * Loads and maps Python environments to their corresponding project paths in the workspace. about O(p × e) where p = projects.len and e = environments.len + * Preserves project selections changed after this refresh began, including queued change notifications. + * Skips unresolvable selections without preventing other projects from restoring their environments. */ - private async loadEnvMap() { - const globals = await this.baseManager.getEnvironments('global'); + private async loadEnvMap(selectionRevisions: ReadonlyMap, toolExecution = false) { + const isCurrent = (key: string) => this.projectSelectionRevisions.get(key) === selectionRevisions.get(key); + const tools = supportsEnvironmentTools(this.baseManager) ? this.baseManager[pythonToolSupport] : undefined; + const globals = await (toolExecution && tools?.getEnvironments + ? tools.getEnvironments('global') + : this.baseManager.getEnvironments('global')); await this.loadGlobalEnv(globals); - this.fsPathToEnv.clear(); + this.fsPathToEnv.forEach((_env, key) => { + if (isCurrent(key)) { + this.fsPathToEnv.delete(key); + } + }); const sorted = sortEnvironments(this.collection); const projects = this.api.getPythonProjects(); @@ -589,6 +771,9 @@ export class VenvManager implements EnvironmentManager { const originalPath = project.uri.fsPath; const normalizedPath = normalizePath(originalPath); const env = await getVenvForWorkspace(originalPath); + if (!isCurrent(normalizedPath)) { + continue; + } if (env) { // from env path find PythonEnvironment object in the collection. let foundEnv = this.findEnvironmentByPath(env, sorted) ?? this.findEnvironmentByPath(env, globals); @@ -602,28 +787,40 @@ export class VenvManager implements EnvironmentManager { this, this.baseManager, ); + if (!isCurrent(normalizedPath)) { + continue; + } if (resolved) { // If resolved; add it to the venvManager collection this.addEnvironment(resolved, false); foundEnv = resolved; } else { this.log.error(`Failed to resolve python environment: ${env}`); - return; + continue; } } // Given found env, add it to the map and fire the event if needed. this.fsPathToEnv.set(normalizedPath, foundEnv); if (previousEnv?.envId.id !== foundEnv.envId.id) { - events.push(() => - this._onDidChangeEnvironment.fire({ uri: project.uri, old: undefined, new: foundEnv }), - ); + events.push(() => { + if (isCurrent(normalizedPath)) { + this._onDidChangeEnvironment.fire({ uri: project.uri, old: undefined, new: foundEnv }); + } + }); } } else { // Search through all known environments (e) and check if any are associated with the current project path. If so, add that environment and path in the map. - const found = sorted.find((e) => { + const projectEnvs = sorted.filter((e) => { const t = this.api.getPythonProject(e.environmentPath)?.uri.fsPath; return t && normalizePath(t) === normalizedPath; }); + // Prefer the project's own environment (e.g. `/.venv`) over one nested in a + // subfolder (e.g. `/tools/.venv`), even when the nested one has a newer Python. + // Broken environments are not preferred, matching their last place in the sort order. + const found = + projectEnvs.find( + (e) => !e.error && normalizePath(path.dirname(e.sysPrefix)) === normalizedPath, + ) ?? projectEnvs[0]; if (found) { this.fsPathToEnv.set(normalizedPath, found); } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index e70cc79d3..31f24447a 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -35,6 +35,13 @@ import { withProgress, } from '../../common/window.apis'; import { getConfiguration } from '../../common/workspace.apis'; +import { + PythonToolError, + PythonToolOperation, + throwIfCancelled, + waitForToolRead, +} from '../../internal/pythonToolSupport'; +import { ProcessTerminationError, ProcessTimeoutError } from '../../common/utils/processRunner'; import { isNativeEnvInfo, NativeEnvInfo, @@ -44,7 +51,7 @@ import { import { getShellActivationCommands, shortenVersionString, sortEnvironments } from '../common/utils'; import { getUvExecutable, runPython, runUV, shouldUseUv } from './helpers'; import { getProjectInstallable, PipPackages, shouldProceedAfterPyprojectValidation } from './pipUtils'; -import { resolveSystemPythonEnvironmentPath } from './utils'; +import { isUvManagedPythonInstall, resolveSystemPythonEnvironmentPath } from './utils'; import { addUvEnvironment, removeUvEnvironment, UV_ENVS_KEY } from './uvEnvironments'; import { createStepBasedVenvFlow } from './venvStepBasedFlow'; @@ -255,6 +262,11 @@ export async function findVirtualEnvironments( ); for (const e of envs) { + // uv-managed Python installations are base interpreters, listed by the Global manager. + if (e.kind === NativePythonEnvironmentKind.venvUv && (await isUvManagedPythonInstall(e))) { + continue; + } + // Include environments with errors (broken environments) so users can see and diagnose them if (e.error) { log.warn(`Broken venv environment detected: ${e.error} - ${JSON.stringify(e)}`); @@ -364,6 +376,7 @@ export interface CreateWithProgressOptions { readonly progressTitle?: string; /** Controls how the created environment's user-facing name is formatted. */ readonly nameStyle?: VenvNameStyle; + readonly operation?: PythonToolOperation; } /** @@ -416,81 +429,144 @@ export async function createWithProgress( options?: CreateWithProgressOptions, ): Promise { const pythonPath = getVenvPythonPath(envPath); - - return await withProgress( - { - location: ProgressLocation.Notification, - title: - options?.progressTitle ?? - l10n.t( - 'Creating virtual environment named {0} using python version {1}.', - path.basename(envPath), - basePython.version, - ), - }, - async () => { - const result: CreateEnvironmentResult = {}; - try { - const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath, venvRoot.fsPath); - // env creation - const baseExecutable = await getBaseInterpreterForVenv(basePython); - if (baseExecutable) { - if (useUv) { - const uvExecutable = await getUvExecutable(log, venvRoot.fsPath); - if (!uvExecutable) { - throw new Error(`uv became unavailable for workspace: ${venvRoot.fsPath}`); - } - await runUV( - ['venv', '--verbose', '--seed', '--python', baseExecutable, envPath], - venvRoot.fsPath, - log, - undefined, - undefined, - uvExecutable, - ); - } else { - await runPython(baseExecutable, ['-m', 'venv', envPath], venvRoot.fsPath, manager.log); - } - if (!(await fsapi.pathExists(pythonPath))) { - throw new Error('no python executable found in virtual environment'); + const operation = options?.operation; + const token = operation?.token; + const create = async (): Promise => { + const result: CreateEnvironmentResult = {}; + try { + throwIfCancelled(token); + const useUv = await shouldUseUv(log, basePython.environmentPath.fsPath, venvRoot.fsPath, token); + const baseExecutable = await getBaseInterpreterForVenv(basePython); + if (operation && !baseExecutable) { + throw new Error(l10n.t('Unable to determine the base Python executable.')); + } + throwIfCancelled(token); + if (baseExecutable) { + if (useUv) { + const uvExecutable = await getUvExecutable(log, venvRoot.fsPath, token); + if (!uvExecutable) { + throw new Error(`uv became unavailable for workspace: ${venvRoot.fsPath}`); } + await runUV( + ['venv', '--verbose', '--seed', '--python', baseExecutable, envPath], + venvRoot.fsPath, + log, + token, + operation ? 300_000 : undefined, + uvExecutable, + !!operation, + ); + } else { + await runPython( + baseExecutable, + ['-m', 'venv', envPath], + venvRoot.fsPath, + manager.log, + token, + operation ? 300_000 : undefined, + !!operation, + ); } - - // handle admin of new env - const resolved = await nativeFinder.resolve(pythonPath); - const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved, options?.nameStyle), manager); - - if ( - trackUvEnvironment && - useUv && - (resolved.kind === NativePythonEnvironmentKind.venvUv || - resolved.kind === NativePythonEnvironmentKind.uvWorkspace) - ) { - await addUvEnvironment(env.environmentPath.fsPath); + throwIfCancelled(token); + if (!(await fsapi.pathExists(pythonPath))) { + throw new Error('no python executable found in virtual environment'); } + } + + const resolution = nativeFinder.resolve(pythonPath); + const resolved = token ? await waitForToolRead(resolution, token) : await resolution; + throwIfCancelled(token); + const env = api.createPythonEnvironmentItem(await getPythonInfo(resolved, options?.nameStyle), manager); + if (operation) { + result.environment = env; + } + if ( + trackUvEnvironment && + useUv && + (resolved.kind === NativePythonEnvironmentKind.venvUv || + resolved.kind === NativePythonEnvironmentKind.uvWorkspace) + ) { + throwIfCancelled(token); + await addUvEnvironment(env.environmentPath.fsPath); + } - // install packages - if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) { - try { - await api.managePackages(env, { - upgrade: false, - install: packages?.install, - uninstall: packages?.uninstall ?? [], - }); - } catch (e) { - // error occurred while installing packages - result.pkgInstallationErr = e instanceof Error ? e.message : String(e); - result.pkgInstallationCancelled = e instanceof CancellationError; + if (packages && (packages.install.length > 0 || packages.uninstall.length > 0)) { + try { + throwIfCancelled(token); + const packageOptions = { + upgrade: false, + install: packages.install, + uninstall: packages.uninstall ?? [], + }; + if (operation) { + await operation.managePackages(env, { ...packageOptions, runHeadless: true }); + } else { + await api.managePackages(env, packageOptions); + } + } catch (error) { + if (operation) { + if (error instanceof CancellationError) { + throw error; + } + throw new PythonToolError( + error instanceof PythonToolError + ? error.code + : error instanceof ProcessTerminationError + ? 'PROCESS_TERMINATION_FAILED' + : error instanceof ProcessTimeoutError + ? 'TIMEOUT' + : 'PACKAGE_INSTALL_FAILED', + l10n.t( + 'The environment was created, but installing project dependencies failed: {0}', + error instanceof Error ? error.message : String(error), + ), + env, + ); } + result.pkgInstallationErr = error instanceof Error ? error.message : String(error); + result.pkgInstallationCancelled = error instanceof CancellationError; } - result.environment = env; - } catch (e) { - log.error(`Failed to create virtual environment: ${e}`); - result.envCreationErr = `Failed to create virtual environment: ${e}`; } - return result; - }, - ); + result.environment = env; + throwIfCancelled(token); + } catch (error) { + log.error(`Failed to create virtual environment: ${error}`); + if (operation) { + if (error instanceof CancellationError || error instanceof PythonToolError) { + throw error; + } + throw new PythonToolError( + error instanceof ProcessTerminationError + ? 'PROCESS_TERMINATION_FAILED' + : error instanceof ProcessTimeoutError + ? 'TIMEOUT' + : 'CREATION_FAILED', + l10n.t( + 'Failed to create virtual environment: {0}', + error instanceof Error ? error.message : String(error), + ), + result.environment, + ); + } + result.envCreationErr = `Failed to create virtual environment: ${error}`; + } + return result; + }; + return operation + ? create() + : withProgress( + { + location: ProgressLocation.Notification, + title: + options?.progressTitle ?? + l10n.t( + 'Creating virtual environment named {0} using python version {1}.', + path.basename(envPath), + basePython.version, + ), + }, + create, + ); } export function ensureGlobalEnv(basePythons: PythonEnvironment[], log: LogOutputChannel): PythonEnvironment[] { @@ -521,6 +597,7 @@ export async function quickCreateVenv( baseEnv: PythonEnvironment, venvRoot: Uri, additionalPackages?: string[], + operation?: PythonToolOperation, ): Promise { const project = api.getPythonProject(venvRoot); @@ -528,6 +605,8 @@ export async function quickCreateVenv( const result = await getProjectInstallable(api, project ? [project] : undefined, { deduplicateProjectPackages: true, preferredRoot: venvRoot, + token: operation?.token, + runHeadless: !!operation, }); const installables = result.installables; const allPackages = []; @@ -537,7 +616,17 @@ export async function quickCreateVenv( } const validationError = result.validationError; - const shouldProceed = await shouldProceedAfterPyprojectValidation(validationError, allPackages); + if (operation && validationError) { + throw new PythonToolError( + 'INVALID_PROJECT', + l10n.t( + 'Fix {0} before installing project dependencies: {1}', + validationError.fileUri.fsPath, + validationError.message, + ), + ); + } + const shouldProceed = operation || (await shouldProceedAfterPyprojectValidation(validationError, allPackages)); if (!shouldProceed) { return undefined; } @@ -545,6 +634,15 @@ export async function quickCreateVenv( // Check if .venv already exists let venvPath = path.join(venvRoot.fsPath, '.venv'); if (await fsapi.pathExists(venvPath)) { + if (operation) { + throw new PythonToolError( + 'ENVIRONMENT_EXISTS', + l10n.t( + 'An environment directory already exists at {0}. Resolve or remove it before creating another environment.', + venvPath, + ), + ); + } // increment to create a unique name, e.g. .venv-1 let i = 1; while (await fsapi.pathExists(`${venvPath}-${i}`)) { @@ -554,10 +652,22 @@ export async function quickCreateVenv( } // createWithProgress handles building CreateEnvironmentResult and adding err msgs - return await createWithProgress(nativeFinder, api, log, manager, baseEnv, venvRoot, venvPath, { - install: allPackages, - uninstall: [], - }); + throwIfCancelled(operation?.token); + return await createWithProgress( + nativeFinder, + api, + log, + manager, + baseEnv, + venvRoot, + venvPath, + { + install: allPackages, + uninstall: [], + }, + true, + { operation }, + ); } export async function createPythonVenv( @@ -686,9 +796,10 @@ export async function resolveVenvPythonEnvironmentPath( const resolved = await nativeFinder.resolve(fsPath); if ( - resolved.kind === NativePythonEnvironmentKind.venv || - resolved.kind === NativePythonEnvironmentKind.venvUv || - resolved.kind === NativePythonEnvironmentKind.uvWorkspace + (resolved.kind === NativePythonEnvironmentKind.venv || + resolved.kind === NativePythonEnvironmentKind.venvUv || + resolved.kind === NativePythonEnvironmentKind.uvWorkspace) && + !(await isUvManagedPythonInstall(resolved)) ) { const envInfo = await getPythonInfo(resolved, nameStyle); return api.createPythonEnvironmentItem(envInfo, manager); diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index e3eaea9a0..70db6daec 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -56,11 +56,12 @@ export async function updatePackagesAndNotify( before: Package[] | undefined, onChanges: PackageChangesCallback, fetchPackages?: PackageFetcher, + skipDirectDependencies = false, ): Promise { const [after, afterDirectDependenciesNames] = await Promise.all([ fetchPackages?.() ?? packageManager.getPackages(environment, { skipCache: true }), // Handle transitive dependencies (best-effort, don't break package refresh on failure) - packageManager.getDirectPackageNames?.(environment).catch(() => undefined), + skipDirectDependencies ? undefined : packageManager.getDirectPackageNames?.(environment).catch(() => undefined), ]); if (after === undefined) { diff --git a/src/managers/common/registeredManagers.ts b/src/managers/common/registeredManagers.ts index 4c929eaa2..37aa0c8d4 100644 --- a/src/managers/common/registeredManagers.ts +++ b/src/managers/common/registeredManagers.ts @@ -11,6 +11,13 @@ import { StopWatch } from '../../common/stopWatch'; import { EventNames } from '../../common/telemetry/constants'; import { classifyError, isTimeoutErrorType } from '../../common/telemetry/errorClassifier'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; +import { + EnvironmentToolSupport, + PackageToolSupport, + pythonToolSupport, + supportsEnvironmentTools, + supportsPackageTools, +} from '../../internal/pythonToolSupport'; import type { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -48,6 +55,10 @@ export class InternalEnvironmentManager implements EnvironmentManager { private readonly manager: EnvironmentManager, ) {} + public get tools(): EnvironmentToolSupport | undefined { + return supportsEnvironmentTools(this.manager) ? this.manager[pythonToolSupport] : undefined; + } + public get name(): string { return this.manager.name; } @@ -70,6 +81,10 @@ export class InternalEnvironmentManager implements EnvironmentManager { return this.manager.log; } + public getVersion(): Promise { + return this.manager.getVersion ? this.manager.getVersion() : Promise.resolve(undefined); + } + public get supportsCreate(): boolean { return this.manager.create !== undefined; } @@ -251,6 +266,10 @@ export class InternalPackageManager implements PackageManager { } } + public get tools(): PackageToolSupport | undefined { + return supportsPackageTools(this.manager) ? this.manager[pythonToolSupport] : undefined; + } + public get name(): string { return this.manager.name; } diff --git a/src/managers/conda/commands/install.ts b/src/managers/conda/commands/install.ts index ad3e04690..58007f6a7 100644 --- a/src/managers/conda/commands/install.ts +++ b/src/managers/conda/commands/install.ts @@ -33,6 +33,12 @@ export class CondaInstallCommand extends InstallCommand { } async execute(executeArgs: InstallExecuteArgs): Promise { - await runCondaExecutable(this.buildCommand(executeArgs), this.log, executeArgs.cancellationToken); + await runCondaExecutable( + this.buildCommand(executeArgs), + this.log, + executeArgs.cancellationToken, + this.timeout ?? (executeArgs.toolExecution ? 300_000 : undefined), + executeArgs.toolExecution, + ); } } diff --git a/src/managers/conda/commands/list.ts b/src/managers/conda/commands/list.ts index b344c10b8..ca2fdf2ac 100644 --- a/src/managers/conda/commands/list.ts +++ b/src/managers/conda/commands/list.ts @@ -34,7 +34,13 @@ export class CondaListCommand extends ListCommand { } async execute(executeArgs?: BaseExecuteArgs): Promise { - const output = await runCondaExecutable(this.buildCommand(), this.log, executeArgs?.cancellationToken); + const output = await runCondaExecutable( + this.buildCommand(), + this.log, + executeArgs?.cancellationToken, + executeArgs?.toolExecution ? this.timeout : undefined, + executeArgs?.toolExecution, + ); let parsed: unknown; try { parsed = JSON.parse(output); @@ -59,6 +65,8 @@ export class CondaListCommand extends ListCommand { version: condaPkg.version, description: condaPkg.version, }); + } else if (executeArgs?.strict) { + throw new CondaListOutputError('Invalid package in conda list output'); } } diff --git a/src/managers/conda/commands/uninstall.ts b/src/managers/conda/commands/uninstall.ts index 53400acf3..57ed0c7cc 100644 --- a/src/managers/conda/commands/uninstall.ts +++ b/src/managers/conda/commands/uninstall.ts @@ -22,6 +22,12 @@ export class CondaUninstallCommand extends UninstallCommand { } async execute(executeArgs: UninstallExecuteArgs): Promise { - await runCondaExecutable(this.buildCommand(executeArgs), this.log, executeArgs.cancellationToken); + await runCondaExecutable( + this.buildCommand(executeArgs), + this.log, + executeArgs.cancellationToken, + executeArgs.toolExecution ? 300_000 : undefined, + executeArgs.toolExecution, + ); } } diff --git a/src/managers/conda/commands/version.ts b/src/managers/conda/commands/version.ts index e9921b2cc..28cbed545 100644 --- a/src/managers/conda/commands/version.ts +++ b/src/managers/conda/commands/version.ts @@ -1,7 +1,7 @@ import type { Pep440Version } from '@renovatebot/pep440'; import { explain as parsePep440Version } from '@renovatebot/pep440'; import { VersionCommand, type BaseExecuteArgs } from '../../base/commands/index'; -import { runCondaExecutable } from '../condaUtils'; +import { runConda } from '../condaUtils'; /** * Conda version command. @@ -14,7 +14,7 @@ export class CondaVersionCommand extends VersionCommand { } async execute(executeArgs?: BaseExecuteArgs): Promise { - const output = await runCondaExecutable(this.buildCommand(), this.log, executeArgs?.cancellationToken); + const output = await runConda(this.buildCommand(), this.log, executeArgs?.cancellationToken); // "conda X.Y.Z" const match = output.match(/conda\s+(\d+\.\d+(?:\.\d+)*)/i); diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index ad4ce00c1..6d64ebcb4 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -1,6 +1,15 @@ import * as fs from 'fs-extra'; import * as path from 'path'; -import { Disposable, EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, Uri } from 'vscode'; +import { + CancellationToken, + Disposable, + EventEmitter, + l10n, + LogOutputChannel, + MarkdownString, + ProgressLocation, + Uri, +} from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -11,6 +20,7 @@ import { GetEnvironmentScope, GetEnvironmentsScope, IconPath, + Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonProject, @@ -27,12 +37,19 @@ import { classifyError } from '../../common/telemetry/errorClassifier'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; +import { + EnvironmentToolSupport, + PythonToolError, + pythonToolSupport, + throwIfCancelled, +} from '../../internal/pythonToolSupport'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import type { PythonProjectManager } from '../../features/projectManager'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { notifyMissingManagerIfDefault } from '../common/utils'; import { constructCondaSourcingStatus, CondaSourcingStatus } from './condaSourcingUtils'; +import { CondaVersionCommand } from './commands/version'; import { checkForNoPythonCondaEnvironment, clearCondaCache, @@ -40,6 +57,7 @@ import { deleteCondaEnvironment, generateName, getConda, + getCondaForTools, getCondaForGlobal, getCondaForWorkspace, getCondaPathSetting, @@ -89,7 +107,93 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { this.fsPathToEnv.clear(); } + public async getVersion(): Promise { + try { + return await new CondaVersionCommand({ pythonExecutable: 'conda', log: this.log }).execute(); + } catch (error) { + traceInfo(`Unable to determine Conda version: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + private _initialized: Deferred | undefined; + private discovery: Deferred | undefined; + private initializationError: unknown; + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => { + await this.initializeDiscovery(); + if (this.initializationError) { + throw this.initializationError; + } + }, + get: (scope) => this.get(scope, true), + getEnvironments: (scope) => this.getEnvironments(scope, true), + resolve: (scope) => this.resolve(scope, true), + create: async (scope, operation) => { + throwIfCancelled(operation.token); + const prefix = path.join(scope.fsPath, '.conda'); + if (await fs.pathExists(prefix)) { + throw new PythonToolError( + 'ENVIRONMENT_EXISTS', + l10n.t( + 'An environment directory already exists at {0}. Resolve or remove it before creating another environment.', + prefix, + ), + ); + } + const environment = await quickCreateConda( + this.api, + this.log, + this, + scope.fsPath, + '.conda', + undefined, + operation, + ); + if (!environment) { + throw new PythonToolError('CREATION_FAILED', l10n.t('Conda did not return a created environment.')); + } + throwIfCancelled(operation.token); + this.addEnvironment(environment); + try { + await fs.writeFile(path.join(prefix, '.gitignore'), '*\n', { flag: 'w' }); + } catch (error) { + throw new PythonToolError( + 'CREATION_FAILED', + l10n.t( + 'The environment was created, but writing its .gitignore failed: {0}', + error instanceof Error ? error.message : String(error), + ), + environment, + ); + } + return environment; + }, + set: (scope, environment, token) => this.set(scope, environment, token), + describe: async (environment, token) => { + throwIfCancelled(token); + const conda = await getCondaForTools(); + throwIfCancelled(token); + return { + ...environment, + execInfo: { + ...environment.execInfo, + activatedRun: { + executable: conda, + args: [ + 'run', + '--prefix', + environment.sysPrefix, + '--no-capture-output', + environment.execInfo.run.executable, + ...(environment.execInfo.run.args ?? []), + ], + }, + }, + }; + }, + }; + async initialize(): Promise { if (this._initialized) { return this._initialized.promise; @@ -117,21 +221,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { toolSource = hasExplicitSetting ? 'settings' : 'local'; } - await withProgress( - { - location: ProgressLocation.Window, - title: CondaStrings.condaDiscovering, - }, - async () => { - this.collection = - (await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? []; - await this.loadEnvMap(); - - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })), - ); - }, - ); + await this.initializeDiscovery(); envCount = this.collection.length; @@ -177,8 +267,55 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - await this.initialize(); + private async initializeDiscovery(): Promise { + if (this.discovery) { + return this.discovery.promise; + } + const discovery = (this.discovery = createDeferred()); + try { + await withProgress( + { location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, + async () => { + this.collection = await this.discoverEnvironments(false); + await this.loadEnvMap(); + this._onDidChangeEnvironments.fire( + this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })), + ); + }, + ); + } catch (error) { + this.initializationError = error; + this.discovery = undefined; + throw error; + } finally { + discovery.resolve(); + } + } + + private async discoverEnvironments(hardRefresh: boolean): Promise { + try { + let discoveryError: unknown; + const environments = await refreshCondaEnvs( + hardRefresh, + this.nativeFinder, + this.api, + this.log, + this, + false, + (error) => { + discoveryError ??= error; + }, + ); + this.initializationError = discoveryError; + return environments; + } catch (error) { + this.initializationError = error; + throw error; + } + } + + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === 'all') { return Array.from(this.collection); @@ -315,7 +452,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { async () => { this.log.info('Refreshing Conda Environments'); const discard = this.collection.map((c) => c); - this.collection = (await refreshCondaEnvs(true, this.nativeFinder, this.api, this.log, this)) ?? []; + this.collection = await this.discoverEnvironments(true); await this.loadEnvMap(); @@ -329,8 +466,8 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { ); } } - async get(scope: GetEnvironmentScope): Promise { - const fastResult = await tryFastPathGet({ + async get(scope: GetEnvironmentScope, toolExecution = false): Promise { + const fastResult = toolExecution ? undefined : await tryFastPathGet({ initialized: this._initialized, setInitialized: (deferred) => { this._initialized = deferred; @@ -340,24 +477,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), getPersistedPath: (fsPath) => getCondaForWorkspace(fsPath), resolve: (p) => resolveCondaPath(p, this.nativeFinder, this.api, this.log, this), - startBackgroundInit: () => - withProgress({ location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, async () => { - this.collection = - (await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? []; - await this.loadEnvMap(); - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.add, - })), - ); - }), + startBackgroundInit: () => this.initializeDiscovery(), }); if (fastResult) { return fastResult.env; } - await this.initialize(); + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope instanceof Uri) { let env = this.fsPathToEnv.get(normalizePath(scope.fsPath)); if (env) { @@ -375,10 +501,25 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { return this.globalEnv; } - async set(scope: SetEnvironmentScope, environment?: PythonEnvironment | undefined): Promise { - const checkedEnv = environment - ? await checkForNoPythonCondaEnvironment(this.nativeFinder, this, environment, this.api, this.log) - : undefined; + async set( + scope: SetEnvironmentScope, + environment?: PythonEnvironment | undefined, + token?: CancellationToken, + ): Promise { + throwIfCancelled(token); + if (token && environment?.version === 'no-python') { + throw new PythonToolError( + 'MISSING_PYTHON', + l10n.t('The selected Conda environment has no Python. Install Python into it before selecting it.'), + environment, + ); + } + const checkedEnv = token + ? environment + : environment + ? await checkForNoPythonCondaEnvironment(this.nativeFinder, this, environment, this.api, this.log) + : undefined; + throwIfCancelled(token); if (scope === undefined) { const before = this.globalEnv; @@ -392,7 +533,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { const fsPath = folder?.uri?.fsPath ?? scope.fsPath; if (fsPath) { // Notify user if CONDA_PREFIX is set and they're trying to select a different environment - if (process.env.CONDA_PREFIX && checkedEnv) { + if (!token && process.env.CONDA_PREFIX && checkedEnv) { const condaPrefixPath = process.env.CONDA_PREFIX; const selectedPath = checkedEnv.environmentPath.fsPath; // Only show notification if they selected a different environment @@ -461,8 +602,8 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } - async resolve(context: ResolveEnvironmentContext): Promise { - await this.initialize(); + async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (context instanceof Uri) { const env = await resolveCondaPath(context.fsPath, this.nativeFinder, this.api, this.log, this); @@ -551,20 +692,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } } else { - // If there is not an environment already assigned by user to this project - // then see if there is one in the collection - if (pathSorted.length === 1) { - this.fsPathToEnv.set(normalizedPath, pathSorted[0]); - } else { - // If there is more than one environment then we need to check if the project - // is a subfolder of one of the environments - const found = pathSorted.find((e) => { - const t = this.api.getPythonProject(e.environmentPath)?.uri.fsPath; - return t && normalizePath(t) === normalizedPath; - }); - if (found) { - this.fsPathToEnv.set(normalizedPath, found); - } + // Infer a local environment only for its owning project, even when just one was found. + const found = pathSorted.find((e) => { + const t = this.api.getPythonProject(e.environmentPath)?.uri.fsPath; + return t && normalizePath(t) === normalizedPath; + }); + if (found) { + this.fsPathToEnv.set(normalizedPath, found); } } } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index f0d9ca4c2..eef0c5646 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -37,6 +37,7 @@ import { CondaVersionCommand, } from './commands/index'; import { getCommonCondaPackagesToInstall } from './condaUtils'; +import { PackageToolSupport, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport'; export class CondaPackageManager implements PackageManager, Disposable { private readonly _onDidChangePackages = new EventEmitter(); @@ -59,7 +60,23 @@ export class CondaPackageManager implements PackageManager, Disposable { tooltip?: string | MarkdownString; iconPath?: IconPath; - async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise { + readonly [pythonToolSupport]: PackageToolSupport = { + manage: (environment, options, token) => this.manage(environment, { ...options, runHeadless: true }, token), + getPackages: async (environment, token) => { + const packages = await this.fetchPackages(environment, token); + if (!packages) { + throw new Error('Conda package listing returned no result.'); + } + return packages; + }, + }; + + async manage( + environment: PythonEnvironment, + options: PackageManagementOptions, + toolToken?: CancellationToken, + ): Promise { + throwIfCancelled(toolToken); let toInstall: string[] = [...(options.install ?? [])]; let toUninstall: string[] = [...(options.uninstall ?? [])]; @@ -89,6 +106,7 @@ export class CondaPackageManager implements PackageManager, Disposable { await command.execute({ packages: parsePackageSpecs(toUninstall), cancellationToken: token, + toolExecution: !!toolToken, }); } @@ -98,6 +116,7 @@ export class CondaPackageManager implements PackageManager, Disposable { packages: parsePackageSpecs(toInstall), upgrade: options.upgrade, cancellationToken: token, + toolExecution: !!toolToken, }); } @@ -108,7 +127,8 @@ export class CondaPackageManager implements PackageManager, Disposable { (changes) => { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, - () => this.fetchPackages(environment), + () => this.fetchPackages(environment, toolToken), + !!toolToken, ); } catch (e) { if (e instanceof CancellationError) { @@ -125,7 +145,7 @@ export class CondaPackageManager implements PackageManager, Disposable { }; if (options.runHeadless) { - await execute(); + await execute(toolToken); return; } @@ -169,18 +189,28 @@ export class CondaPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } - private async fetchPackages(environment: PythonEnvironment): Promise { + private async fetchPackages( + environment: PythonEnvironment, + token?: CancellationToken, + ): Promise { + throwIfCancelled(token); const listCmd = new CondaListCommand({ pythonExecutable: 'conda', condaEnvironmentPath: environment.environmentPath.fsPath, log: this.log, }); try { - const data = await listCmd.execute(); + const data = await listCmd.execute( + token ? { cancellationToken: token, strict: true, toolExecution: true } : undefined, + ); + throwIfCancelled(token); const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); this.packages.set(environment.envId.id, packages); return packages; } catch (error) { + if (token) { + throw error; + } if (error instanceof CondaListOutputError) { this.log.error('Error parsing installed Conda packages', error); return undefined; diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 98a92a396..426137adb 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -1,6 +1,8 @@ import * as fse from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; +import { PythonToolError, PythonToolOperation, throwIfCancelled } from '../../internal/pythonToolSupport'; +import { ProcessTerminationError, ProcessTimeoutError, runLoggedProcess } from '../../common/utils/processRunner'; import { CancellationError, CancellationToken, @@ -31,7 +33,6 @@ import { getWorkspacePersistentState } from '../../common/persistentState'; import { pickProject } from '../../common/pickers/projects'; import { PythonVersion } from '../../common/pythonVersion'; import { StopWatch } from '../../common/stopWatch'; -import { createDeferred } from '../../common/utils/deferred'; import { untildify } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { @@ -201,54 +202,92 @@ export async function getConda(native?: NativePythonFinder): Promise { return await getCondaExecutable(native); } +/** Resolves a configured command name for tool execution and portable command descriptors. */ +export async function getCondaForTools(): Promise { + const command = await getConda(); + const resolved = path.resolve(path.isAbsolute(command) ? command : await which(command)); + if (!isWindows() || !['.bat', '.cmd'].includes(path.extname(resolved).toLowerCase())) { + return resolved; + } + + const directory = path.dirname(resolved); + const executableName = `${path.basename(resolved, path.extname(resolved))}.exe`; + const candidates = [ + path.join(directory, executableName), + path.join(directory, 'Scripts', 'conda.exe'), + path.join(directory, '_conda.exe'), + path.resolve(directory, '..', 'Scripts', 'conda.exe'), + path.resolve(directory, '..', '_conda.exe'), + ]; + for (const candidate of new Set(candidates.map((value) => path.resolve(value)))) { + if (await fse.pathExists(candidate)) { + return candidate; + } + } + + throw new PythonToolError( + 'CONDA_EXECUTABLE_NOT_FOUND', + l10n.t( + 'Conda tool operations cannot launch the command script at {0}. Set python.condaPath to a conda.exe executable.', + resolved, + ), + ); +} + async function _runConda( conda: string, args: string[], log?: LogOutputChannel, token?: CancellationToken, + timeout?: number, + toolExecution = false, ): Promise { - const deferred = createDeferred(); - args = quoteArgs(args); - const quotedConda = quoteStringIfNecessary(conda); + const command = toolExecution ? conda : quoteStringIfNecessary(conda); + const commandArgs = toolExecution ? args : quoteArgs(args); const timer = new StopWatch(); - deferred.promise.finally(() => traceInfo(`Ran conda in ${timer.elapsedTime}: ${quotedConda} ${args.join(' ')}`)); - const proc = spawnProcess(quotedConda, args, { shell: true }); - - token?.onCancellationRequested(() => { - proc.kill(); - deferred.reject(new CancellationError()); - }); - - proc.on('error', (err) => { - log?.error(`Error spawning conda: ${err}`); - deferred.reject(new Error(`Error spawning conda: ${err.message}`)); - }); - - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (data) => { - const d = data.toString('utf-8'); - stdout += d; - log?.info(d.trim()); - }); - proc.stderr?.on('data', (data) => { - const d = data.toString('utf-8'); - stderr += d; - log?.error(d.trim()); - }); - proc.on('close', () => { - deferred.resolve(stdout); - }); - proc.on('exit', (code) => { - if (code !== 0) { - deferred.reject(new Error(`Failed to run "conda ${args.join(' ')}":\n ${stderr}`)); + try { + if (toolExecution) { + return await runLoggedProcess(command, commandArgs, { shell: false, windowsHide: true }, log, token, timeout); } - }); - - return deferred.promise; + return await new Promise((resolve, reject) => { + const proc = spawnProcess(command, commandArgs, { shell: true }); + token?.onCancellationRequested(() => { + proc.kill(); + reject(new CancellationError()); + }); + proc.on('error', (err) => { + log?.error(`Error spawning conda: ${err}`); + reject(new Error(`Error spawning conda: ${err.message}`)); + }); + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (data) => { + const value = data.toString('utf-8'); + stdout += value; + log?.info(value.trim()); + }); + proc.stderr?.on('data', (data) => { + const value = data.toString('utf-8'); + stderr += value; + log?.error(value.trim()); + }); + proc.on('close', () => resolve(stdout)); + proc.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Failed to run "conda ${commandArgs.join(' ')}":\n ${stderr}`)); + } + }); + }); + } finally { + traceInfo(`Ran conda in ${timer.elapsedTime}: ${command} ${commandArgs.join(' ')}`); + } } -async function runConda(args: string[], log?: LogOutputChannel, token?: CancellationToken): Promise { +export async function runConda( + args: string[], + log?: LogOutputChannel, + token?: CancellationToken, +): Promise { const conda = await getConda(); return await _runConda(conda, args, log, token); } @@ -257,9 +296,14 @@ export async function runCondaExecutable( args: string[], log?: LogOutputChannel, token?: CancellationToken, + timeout?: number, + toolExecution = false, ): Promise { - const conda = await getCondaExecutable(undefined); - return await _runConda(conda, args, log, token); + if (toolExecution) { + throwIfCancelled(token); + } + const conda = toolExecution ? await getCondaForTools() : await getCondaExecutable(undefined); + return await _runConda(conda, args, log, token, timeout, toolExecution); } let prefixes: string[] | undefined; @@ -841,6 +885,8 @@ export async function refreshCondaEnvs( api: PythonEnvironmentApi, log: LogOutputChannel, manager: EnvironmentManager, + strict = false, + onError?: (error: unknown) => void, ): Promise { log.info(`Refreshing conda environments (hardRefresh=${hardRefresh})`); @@ -850,6 +896,10 @@ export async function refreshCondaEnvs( } catch (error) { traceError('Failed to refresh native finder for conda environments', error); log.error(`Failed to refresh native finder: ${error instanceof Error ? error.message : String(error)}`); + onError?.(error); + if (strict) { + throw error; + } return []; } @@ -857,6 +907,15 @@ export async function refreshCondaEnvs( if (!data || !Array.isArray(data)) { traceWarn(`Native finder returned invalid data: ${typeof data}, expected array`); log.warn(`Native finder returned invalid data type: ${typeof data}`); + const error = new Error( + l10n.t( + 'Conda discovery returned invalid data. Retry environment discovery before configuring an environment.', + ), + ); + onError?.(error); + if (strict) { + throw error; + } return []; } @@ -911,6 +970,10 @@ export async function refreshCondaEnvs( error instanceof Error ? error.message : String(error) }`, ); + onError?.(error); + if (strict) { + throw error; + } } }), ); @@ -1189,38 +1252,77 @@ export async function quickCreateConda( fsPath: string, name: string, additionalPackages?: string[], + operation?: PythonToolOperation, ): Promise { const prefix = path.join(fsPath, name); const execPath = os.platform() === 'win32' ? path.join(prefix, 'python.exe') : path.join(prefix, 'bin', 'python'); - return await withProgress( - { - location: ProgressLocation.Notification, - title: `Creating conda environment: ${name}`, - }, - async () => { - try { - const conda = await getConda(); - await runCondaExecutable(['create', '--yes', '--prefix', prefix, 'python'], log); - if (additionalPackages && additionalPackages.length > 0) { - await runConda(['install', '--yes', '--prefix', prefix, ...additionalPackages], log); - } - const version = await getVersion(prefix); + const create = async () => { + let environment: PythonEnvironment | undefined; + try { + throwIfCancelled(operation?.token); + const conda = await getConda(); + const baseVersion = operation?.baseEnvironment + ? PythonVersion.tryParse(operation.baseEnvironment.version) + : undefined; + await runCondaExecutable( + [ + 'create', + '--yes', + '--prefix', + prefix, + baseVersion ? `python=${baseVersion.major}.${baseVersion.minor}` : 'python', + ], + log, + operation?.token, + operation ? 300_000 : undefined, + !!operation, + ); + throwIfCancelled(operation?.token); + if (!operation && additionalPackages && additionalPackages.length > 0) { + await runConda(['install', '--yes', '--prefix', prefix, ...additionalPackages], log); + } + const version = await getVersion(prefix); - // Use proper prefix-based activation with actual conda path - const environment = api.createPythonEnvironmentItem( - await getPrefixesCondaPythonInfo(prefix, execPath, version, conda, manager), - manager, + // Use proper prefix-based activation with actual conda path + environment = api.createPythonEnvironmentItem( + await getPrefixesCondaPythonInfo(prefix, execPath, version, conda, manager), + manager, + ); + if (operation && additionalPackages && additionalPackages.length > 0) { + await operation.managePackages(environment, { install: additionalPackages, runHeadless: true }); + } + throwIfCancelled(operation?.token); + return environment; + } catch (e) { + log.error('Failed to create conda environment', e); + if (operation) { + if (e instanceof CancellationError || e instanceof PythonToolError) { + throw e; + } + throw new PythonToolError( + e instanceof ProcessTerminationError + ? 'PROCESS_TERMINATION_FAILED' + : e instanceof ProcessTimeoutError + ? 'TIMEOUT' + : environment + ? 'PACKAGE_INSTALL_FAILED' + : 'CREATION_FAILED', + l10n.t('Failed to set up the Conda environment: {0}', e instanceof Error ? e.message : String(e)), + environment, ); - return environment; - } catch (e) { - log.error('Failed to create conda environment', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log); - }); } - }, - ); + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaCreateFailed, log); + }); + } + }; + return operation + ? create() + : withProgress( + { location: ProgressLocation.Notification, title: `Creating conda environment: ${name}` }, + create, + ); } export async function deleteCondaEnvironment(environment: PythonEnvironment, log: LogOutputChannel): Promise { diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts index 583811e69..250c4fffc 100644 --- a/src/managers/pipenv/pipenvManager.ts +++ b/src/managers/pipenv/pipenvManager.ts @@ -1,4 +1,5 @@ -import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri, workspace } from 'vscode'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; +import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -7,6 +8,7 @@ import { GetEnvironmentScope, GetEnvironmentsScope, IconPath, + Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonProject, @@ -23,15 +25,22 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { withProgress } from '../../common/window.apis'; +import { getConfiguration } from '../../common/workspace.apis'; import type { PythonProjectManager } from '../../features/projectManager'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; -import { NativePythonFinder } from '../common/nativePythonFinder'; +import { NativePythonEnvironmentKind, NativePythonFinder } from '../common/nativePythonFinder'; import { notifyMissingManagerIfDefault } from '../common/utils'; +import { + EnvironmentToolSupport, + pythonToolSupport, + resolveToolProjectEnvironment, +} from '../../internal/pythonToolSupport'; import { clearPipenvCache, getPipenv, getPipenvForGlobal, getPipenvForWorkspace, + getPipenvVersion, refreshPipenv, resolvePipenvPath, setPipenvForGlobal, @@ -77,6 +86,33 @@ export class PipenvManager implements EnvironmentManager, Disposable { this._onDidChangeEnvironments.dispose(); } + public async getVersion(): Promise { + const pipenv = await getPipenv(); + if (!pipenv) { + return undefined; + } + const version = await getPipenvVersion(pipenv); + return version ? (parsePep440Version(version) ?? undefined) : undefined; + } + + private discovery: Deferred | undefined; + private initializationError: unknown; + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => { + await this.initializeDiscovery(); + if (this.initializationError) { + throw this.initializationError; + } + }, + get: (scope) => this.get(scope, true), + getEnvironments: (scope) => this.getEnvironments(scope, true), + resolve: (scope) => this.resolve(scope, true), + resolveProject: (scope) => + resolveToolProjectEnvironment(scope, NativePythonEnvironmentKind.pipenv, this.nativeFinder, (uri) => + this.resolve(uri, true), + ), + }; + async initialize(): Promise { if (this._initialized) { return this._initialized.promise; @@ -90,26 +126,13 @@ export class PipenvManager implements EnvironmentManager, Disposable { try { // Check if tool is findable before PET refresh (settings/cache/PATH only, no PET) - const hasExplicitSetting = !!workspace.getConfiguration('python').get('pipenvPath'); + const hasExplicitSetting = !!getConfiguration('python').get('pipenvPath'); const preRefreshTool = await getPipenv(); if (preRefreshTool) { toolSource = hasExplicitSetting ? 'settings' : 'local'; } - await withProgress( - { - location: ProgressLocation.Window, - title: PipenvStrings.pipenvDiscovering, - }, - async () => { - this.collection = (await refreshPipenv(false, this.nativeFinder, this.api, this)) ?? []; - await this.loadEnvMap(); - - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })), - ); - }, - ); + await this.initializeDiscovery(); envCount = this.collection.length; @@ -141,6 +164,32 @@ export class PipenvManager implements EnvironmentManager, Disposable { } } + private async initializeDiscovery(): Promise { + if (this.discovery) { + return this.discovery.promise; + } + const discovery = (this.discovery = createDeferred()); + try { + await withProgress( + { location: ProgressLocation.Window, title: PipenvStrings.pipenvDiscovering }, + async () => { + this.collection = (await refreshPipenv(false, this.nativeFinder, this.api, this)) ?? []; + await this.loadEnvMap(); + this.initializationError = undefined; + this._onDidChangeEnvironments.fire( + this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })), + ); + }, + ); + } catch (error) { + this.initializationError = error; + this.discovery = undefined; + throw error; + } finally { + discovery.resolve(); + } + } + private async loadEnvMap() { // Load environment mappings for projects const projects = this.api.getPythonProjects(); @@ -183,6 +232,7 @@ export class PipenvManager implements EnvironmentManager, Disposable { const oldCollection = [...this.collection]; this.collection = (await refreshPipenv(hardRefresh, this.nativeFinder, this.api, this)) ?? []; await this.loadEnvMap(); + this.initializationError = undefined; // Fire change events for environments that were added or removed const changes: { environment: PythonEnvironment; kind: EnvironmentChangeKind }[] = []; @@ -208,8 +258,8 @@ export class PipenvManager implements EnvironmentManager, Disposable { ); } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - await this.initialize(); + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === 'all') { return Array.from(this.collection); @@ -302,8 +352,8 @@ export class PipenvManager implements EnvironmentManager, Disposable { } } - async get(scope: GetEnvironmentScope): Promise { - const fastResult = await tryFastPathGet({ + async get(scope: GetEnvironmentScope, toolExecution = false): Promise { + const fastResult = toolExecution ? undefined : await tryFastPathGet({ initialized: this._initialized, setInitialized: (deferred) => { this._initialized = deferred; @@ -313,26 +363,13 @@ export class PipenvManager implements EnvironmentManager, Disposable { getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), getPersistedPath: (fsPath) => getPipenvForWorkspace(fsPath), resolve: (p) => resolvePipenvPath(p, this.nativeFinder, this.api, this), - startBackgroundInit: () => - withProgress( - { location: ProgressLocation.Window, title: PipenvStrings.pipenvDiscovering }, - async () => { - this.collection = (await refreshPipenv(false, this.nativeFinder, this.api, this)) ?? []; - await this.loadEnvMap(); - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.add, - })), - ); - }, - ), + startBackgroundInit: () => this.initializeDiscovery(), }); if (fastResult) { return fastResult.env; } - await this.initialize(); + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === undefined) { return this.globalEnv; @@ -348,8 +385,8 @@ export class PipenvManager implements EnvironmentManager, Disposable { return undefined; } - async resolve(context: ResolveEnvironmentContext): Promise { - await this.initialize(); + async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); return resolvePipenvPath(context.fsPath, this.nativeFinder, this.api, this); } @@ -359,5 +396,7 @@ export class PipenvManager implements EnvironmentManager, Disposable { this.fsPathToEnv.clear(); this.globalEnv = undefined; this._initialized = undefined; + this.discovery = undefined; + this.initializationError = undefined; } } diff --git a/src/managers/pipenv/pipenvUtils.ts b/src/managers/pipenv/pipenvUtils.ts index 55e375e91..764f6ad2f 100644 --- a/src/managers/pipenv/pipenvUtils.ts +++ b/src/managers/pipenv/pipenvUtils.ts @@ -13,6 +13,7 @@ import { PythonEnvironmentApi, PythonEnvironmentInfo, } from '../../api'; +import { execProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo, traceVerbose } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; @@ -105,6 +106,24 @@ export async function getPipenv(): Promise { } } +/** + * Returns the version reported by the Pipenv executable. + * + * @param pipenv Path to the Pipenv executable. + * @returns The version string, or `undefined` when it cannot be determined. + */ +export async function getPipenvVersion(pipenv: string): Promise { + try { + const { stdout } = await execProcess(`"${pipenv}" --version`); + traceInfo(`Pipenv version output: ${stdout.trim()}`); + const match = stdout.match(/pipenv,?\s+(?:version\s+)?(\d+(?:\.\d+)+(?:[a-z0-9.-]*)?)/i); + return match?.[1]; + } catch (error) { + traceInfo(`Unable to determine Pipenv version: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + async function nativeToPythonEnv( info: NativeEnvInfo, api: PythonEnvironmentApi, diff --git a/src/managers/poetry/commands/add.ts b/src/managers/poetry/commands/add.ts index 972c711e8..868b801ba 100644 --- a/src/managers/poetry/commands/add.ts +++ b/src/managers/poetry/commands/add.ts @@ -22,6 +22,12 @@ export class PoetryAddCommand extends InstallCommand { } async execute(executeArgs: InstallExecuteArgs): Promise { - await runPoetry(this.buildCommand(executeArgs), this.cwd, this.log, executeArgs.cancellationToken); + await runPoetry( + this.buildCommand(executeArgs), + this.cwd, + this.log, + executeArgs.cancellationToken, + executeArgs.toolExecution, + ); } } diff --git a/src/managers/poetry/commands/remove.ts b/src/managers/poetry/commands/remove.ts index 6d27e79e0..fcc529e1b 100644 --- a/src/managers/poetry/commands/remove.ts +++ b/src/managers/poetry/commands/remove.ts @@ -12,6 +12,12 @@ export class PoetryRemoveCommand extends UninstallCommand { } async execute(executeArgs: UninstallExecuteArgs): Promise { - await runPoetry(this.buildCommand(executeArgs), this.cwd, this.log, executeArgs.cancellationToken); + await runPoetry( + this.buildCommand(executeArgs), + this.cwd, + this.log, + executeArgs.cancellationToken, + executeArgs.toolExecution, + ); } } diff --git a/src/managers/poetry/commands/runPoetry.ts b/src/managers/poetry/commands/runPoetry.ts index 092fe0a7c..f9edb844b 100644 --- a/src/managers/poetry/commands/runPoetry.ts +++ b/src/managers/poetry/commands/runPoetry.ts @@ -1,5 +1,7 @@ import { CancellationError, CancellationToken, LogOutputChannel } from 'vscode'; import { spawnProcess } from '../../../common/childProcess.apis'; +import { runLoggedProcess } from '../../../common/utils/processRunner'; +import { throwIfCancelled } from '../../../internal/pythonToolSupport'; import { getPoetry } from '../poetryUtils'; export async function runPoetry( @@ -7,12 +9,19 @@ export async function runPoetry( cwd?: string, log?: LogOutputChannel, token?: CancellationToken, + toolExecution = false, ): Promise { + if (toolExecution) { + throwIfCancelled(token); + } const poetry = await getPoetry(); if (!poetry) { throw new Error('Poetry executable not found'); } + if (toolExecution) { + return runLoggedProcess(poetry, ['--no-interaction', ...args], { cwd }, log, token, 300_000, true); + } log?.info(`Running: ${poetry} ${args.join(' ')}`); return new Promise((resolve, reject) => { diff --git a/src/managers/poetry/poetryManager.ts b/src/managers/poetry/poetryManager.ts index 42854b8e7..00041932b 100644 --- a/src/managers/poetry/poetryManager.ts +++ b/src/managers/poetry/poetryManager.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri, workspace } from 'vscode'; +import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, @@ -8,6 +8,7 @@ import { GetEnvironmentScope, GetEnvironmentsScope, IconPath, + Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonProject, @@ -24,9 +25,15 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { withProgress } from '../../common/window.apis'; +import { getConfiguration } from '../../common/workspace.apis'; import type { PythonProjectManager } from '../../features/projectManager'; -import { NativePythonFinder } from '../common/nativePythonFinder'; +import { NativePythonEnvironmentKind, NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, notifyMissingManagerIfDefault } from '../common/utils'; +import { + EnvironmentToolSupport, + pythonToolSupport, + resolveToolProjectEnvironment, +} from '../../internal/pythonToolSupport'; import { clearPoetryCache, getPoetry, @@ -39,6 +46,7 @@ import { setPoetryForWorkspace, setPoetryForWorkspaces, } from './poetryUtils'; +import { PoetryVersionCommand } from './commands/version'; export class PoetryManager implements EnvironmentManager, Disposable { private collection: PythonEnvironment[] = []; @@ -74,7 +82,33 @@ export class PoetryManager implements EnvironmentManager, Disposable { this.fsPathToEnv.clear(); } + public async getVersion(): Promise { + const poetry = await getPoetry(); + if (!poetry) { + return undefined; + } + return await new PoetryVersionCommand({ pythonExecutable: poetry }).execute(); + } + private _initialized: Deferred | undefined; + private discovery: Deferred | undefined; + private initializationError: unknown; + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => { + await this.initializeDiscovery(); + if (this.initializationError) { + throw this.initializationError; + } + }, + get: (scope) => this.get(scope, true), + getEnvironments: (scope) => this.getEnvironments(scope, true), + resolve: (scope) => this.resolve(scope, true), + resolveProject: (scope) => + resolveToolProjectEnvironment(scope, NativePythonEnvironmentKind.poetry, this.nativeFinder, (uri) => + this.resolve(uri, true), + ), + }; + async initialize(): Promise { if (this._initialized) { return this._initialized.promise; @@ -88,26 +122,13 @@ export class PoetryManager implements EnvironmentManager, Disposable { try { // Check if tool is findable before PET refresh (settings/cache/PATH only, no PET) - const hasExplicitSetting = !!workspace.getConfiguration('python').get('poetryPath'); + const hasExplicitSetting = !!getConfiguration('python').get('poetryPath'); const preRefreshTool = await getPoetry(); if (preRefreshTool) { toolSource = hasExplicitSetting ? 'settings' : 'local'; } - await withProgress( - { - location: ProgressLocation.Window, - title: PoetryStrings.poetryDiscovering, - }, - async () => { - this.collection = (await refreshPoetry(false, this.nativeFinder, this.api, this)) ?? []; - await this.loadEnvMap(); - - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })), - ); - }, - ); + await this.initializeDiscovery(); envCount = this.collection.length; @@ -139,8 +160,34 @@ export class PoetryManager implements EnvironmentManager, Disposable { } } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - await this.initialize(); + private async initializeDiscovery(): Promise { + if (this.discovery) { + return this.discovery.promise; + } + const discovery = (this.discovery = createDeferred()); + try { + await withProgress( + { location: ProgressLocation.Window, title: PoetryStrings.poetryDiscovering }, + async () => { + this.collection = (await refreshPoetry(false, this.nativeFinder, this.api, this)) ?? []; + await this.loadEnvMap(); + this.initializationError = undefined; + this._onDidChangeEnvironments.fire( + this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })), + ); + }, + ); + } catch (error) { + this.initializationError = error; + this.discovery = undefined; + throw error; + } finally { + discovery.resolve(); + } + } + + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === 'all') { return Array.from(this.collection); @@ -175,6 +222,7 @@ export class PoetryManager implements EnvironmentManager, Disposable { this.collection = (await refreshPoetry(true, this.nativeFinder, this.api, this)) ?? []; await this.loadEnvMap(); + this.initializationError = undefined; const args = [ ...discard.map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })), @@ -187,8 +235,8 @@ export class PoetryManager implements EnvironmentManager, Disposable { } } - async get(scope: GetEnvironmentScope): Promise { - await this.initialize(); + async get(scope: GetEnvironmentScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope instanceof Uri) { let env = this.fsPathToEnv.get(normalizePath(scope.fsPath)); if (env) { @@ -256,8 +304,8 @@ export class PoetryManager implements EnvironmentManager, Disposable { } } - async resolve(context: ResolveEnvironmentContext): Promise { - await this.initialize(); + async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (context instanceof Uri) { const env = await resolvePoetryPath(context.fsPath, this.nativeFinder, this.api, this); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index a62b30c92..c79bf1ec4 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -1,4 +1,5 @@ import type { Pep440Version } from '@renovatebot/pep440'; +import * as fsapi from 'fs-extra'; import * as path from 'path'; import { CancellationError, @@ -11,6 +12,7 @@ import { MarkdownString, ProgressLocation, ThemeIcon, + Uri, } from 'vscode'; import { Disposable } from 'vscode-jsonrpc'; import { @@ -28,6 +30,7 @@ import { import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; import * as workspaceFs from '../../common/workspace.fs.apis'; import { PackageManagerRequiresProjectError } from '../common/errors'; +import { normalizePath } from '../../common/utils/pathUtils'; import { updatePackagesAndNotify } from '../common/packageChanges'; import { parsePackageSpecs } from '../common/packageUtils'; import { @@ -39,6 +42,8 @@ import { } from './commands/index'; import { PoetryManager } from './poetryManager'; import { getPoetry } from './poetryUtils'; +import { runPoetry } from './commands/runPoetry'; +import { PackageToolSupport, PythonToolError, pythonToolSupport, throwIfCancelled } from '../../internal/pythonToolSupport'; export class PoetryPackageManager implements PackageManager, Disposable { private readonly packagesChangedEmitter = new EventEmitter(); @@ -79,8 +84,90 @@ export class PoetryPackageManager implements PackageManager, Disposable { ); } - async manage(environment: PythonEnvironment, options: PackageManagementOptions): Promise { - const cwd = await this.getProjectCwd(); + readonly [pythonToolSupport]: PackageToolSupport = { + manage: async (environment, options, token, scope) => { + await this.verifyToolEnvironment(environment, scope, token); + await this.manage(environment, { ...options, runHeadless: true }, token, scope); + }, + getPackages: async (environment, token, scope) => { + await this.verifyToolEnvironment(environment, scope, token); + return this.fetchInstalledPackagesForTools(environment, token, scope); + }, + }; + + private async verifyToolEnvironment( + environment: PythonEnvironment, + scope: Uri, + token: CancellationToken, + ): Promise { + throwIfCancelled(token); + const target = (await runPoetry(['env', 'info', '--path'], scope.fsPath, this.log, token, true)).trim(); + throwIfCancelled(token); + const actual = path.isAbsolute(target) ? await fsapi.realpath(target) : undefined; + const selected = environment.sysPrefix ? await fsapi.realpath(environment.sysPrefix) : undefined; + if (!actual || !selected || normalizePath(actual) !== normalizePath(selected)) { + throw new PythonToolError( + 'ENVIRONMENT_MISMATCH', + l10n.t( + 'Poetry for {0} targets {1}, not the selected environment {2}. Select this project\'s Poetry environment before querying or installing packages.', + scope.fsPath, + target || l10n.t('no environment'), + environment.sysPrefix, + ), + environment, + ); + } + throwIfCancelled(token); + } + + private async fetchInstalledPackagesForTools( + environment: PythonEnvironment, + token: CancellationToken, + scope: Uri, + ): Promise { + // poetry show describes the lockfile, not the installed distributions. + const output = await runPoetry( + ['run', 'pip', 'list', '--format=json', '--disable-pip-version-check'], + scope.fsPath, + this.log, + token, + true, + ); + throwIfCancelled(token); + const data: unknown = JSON.parse(output); + if ( + !Array.isArray(data) || + data.some( + (item) => + !item || + typeof item.name !== 'string' || + !item.name || + typeof item.version !== 'string' || + !item.version, + ) + ) { + throw new PythonToolError( + 'PACKAGE_QUERY_FAILED', + l10n.t('Poetry returned invalid installed-package information.'), + environment, + ); + } + return data.map(({ name, version }: { name: string; version: string }) => + this.api.createPackageItem({ name, displayName: name, version, description: version }, environment, this), + ); + } + + async manage( + environment: PythonEnvironment, + options: PackageManagementOptions, + toolToken?: CancellationToken, + scope?: Uri, + ): Promise { + throwIfCancelled(toolToken); + if (toolToken && !scope) { + throw new PythonToolError('INVALID_REQUEST', l10n.t('A project scope is required for Poetry tools.')); + } + const cwd = toolToken && scope ? scope.fsPath : await this.getProjectCwd(); let toInstall: string[] = [...(options.install ?? [])]; let toUninstall: string[] = [...(options.uninstall ?? [])]; @@ -109,7 +196,12 @@ export class PoetryPackageManager implements PackageManager, Disposable { const execute = async (token?: CancellationToken): Promise => { try { - await this.runPoetryManage({ install: toInstall, uninstall: toUninstall }, cwd, token); + await this.runPoetryManage( + { install: toInstall, uninstall: toUninstall }, + cwd, + token, + !!toolToken, + ); await updatePackagesAndNotify( this, environment, @@ -117,6 +209,10 @@ export class PoetryPackageManager implements PackageManager, Disposable { (changes) => { this.packagesChangedEmitter.fire({ environment, manager: this, changes }); }, + toolToken && scope + ? () => this.fetchInstalledPackagesForTools(environment, toolToken, scope) + : undefined, + !!toolToken, ); } catch (e) { if (e instanceof CancellationError) { @@ -136,7 +232,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { }; if (options.runHeadless) { - await execute(); + await execute(toolToken); return; } @@ -231,7 +327,11 @@ export class PoetryPackageManager implements PackageManager, Disposable { options: { install?: string[]; uninstall?: string[] }, cwd: string, token?: CancellationToken, + toolExecution = false, ): Promise { + if (toolExecution) { + throwIfCancelled(token); + } const poetry = await getPoetry(); if (!poetry) { throw new Error( @@ -248,7 +348,7 @@ export class PoetryPackageManager implements PackageManager, Disposable { log: this.log, }); const packages = parsePackageSpecs(options.uninstall); - await removeCmd.execute({ packages, cancellationToken: token }); + await removeCmd.execute({ packages, cancellationToken: token, toolExecution }); } // Handle installs @@ -259,7 +359,11 @@ export class PoetryPackageManager implements PackageManager, Disposable { log: this.log, }); const packages = parsePackageSpecs(options.install); - await addCmd.execute({ packages, cancellationToken: token }); + await addCmd.execute({ packages, cancellationToken: token, toolExecution }); + if (toolExecution) { + // poetry add skips already-declared dependencies, even when they are not installed. + await runPoetry(['install', '--no-root'], cwd, this.log, token, true); + } } } diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index ec5be1fc2..b1c2e914d 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -1,3 +1,4 @@ +import { explain as parsePep440Version } from '@renovatebot/pep440'; import * as path from 'path'; import { Disposable, EventEmitter, MarkdownString, ProgressLocation, Uri } from 'vscode'; import { @@ -8,6 +9,7 @@ import { GetEnvironmentScope, GetEnvironmentsScope, IconPath, + Pep440Version, PythonEnvironment, PythonEnvironmentApi, PythonProject, @@ -29,11 +31,13 @@ import type { PythonProjectManager } from '../../features/projectManager'; import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, notifyMissingManagerIfDefault } from '../common/utils'; +import { EnvironmentToolSupport, pythonToolSupport } from '../../internal/pythonToolSupport'; import { clearPyenvCache, getPyenv, getPyenvForGlobal, getPyenvForWorkspace, + getPyenvVersion, PYENV_VERSIONS, refreshPyenv, resolvePyenvPath, @@ -76,7 +80,30 @@ export class PyEnvManager implements EnvironmentManager, Disposable { this.fsPathToEnv.clear(); } + public async getVersion(): Promise { + const pyenv = await getPyenv(); + if (!pyenv) { + return undefined; + } + const version = await getPyenvVersion(pyenv); + return version ? (parsePep440Version(version) ?? undefined) : undefined; + } + private _initialized: Deferred | undefined; + private discovery: Deferred | undefined; + private initializationError: unknown; + readonly [pythonToolSupport]: EnvironmentToolSupport = { + initialize: async () => { + await this.initializeDiscovery(); + if (this.initializationError) { + throw this.initializationError; + } + }, + get: (scope) => this.get(scope, true), + getEnvironments: (scope) => this.getEnvironments(scope, true), + resolve: (scope) => this.resolve(scope, true), + }; + async initialize(): Promise { if (this._initialized) { return this._initialized.promise; @@ -95,20 +122,7 @@ export class PyEnvManager implements EnvironmentManager, Disposable { toolSource = 'local'; } - await withProgress( - { - location: ProgressLocation.Window, - title: PyenvStrings.pyenvDiscovering, - }, - async () => { - this.collection = (await refreshPyenv(false, this.nativeFinder, this.api, this)) ?? []; - await this.loadEnvMap(); - - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ environment: e, kind: EnvironmentChangeKind.add })), - ); - }, - ); + await this.initializeDiscovery(); envCount = this.collection.length; @@ -140,8 +154,34 @@ export class PyEnvManager implements EnvironmentManager, Disposable { } } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - await this.initialize(); + private async initializeDiscovery(): Promise { + if (this.discovery) { + return this.discovery.promise; + } + const discovery = (this.discovery = createDeferred()); + try { + await withProgress( + { location: ProgressLocation.Window, title: PyenvStrings.pyenvDiscovering }, + async () => { + this.collection = (await refreshPyenv(false, this.nativeFinder, this.api, this)) ?? []; + await this.loadEnvMap(); + this.initializationError = undefined; + this._onDidChangeEnvironments.fire( + this.collection.map((environment) => ({ environment, kind: EnvironmentChangeKind.add })), + ); + }, + ); + } catch (error) { + this.initializationError = error; + this.discovery = undefined; + throw error; + } finally { + discovery.resolve(); + } + } + + async getEnvironments(scope: GetEnvironmentsScope, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope === 'all') { return Array.from(this.collection); @@ -174,6 +214,7 @@ export class PyEnvManager implements EnvironmentManager, Disposable { this.collection = (await refreshPyenv(true, this.nativeFinder, this.api, this)) ?? []; await this.loadEnvMap(); + this.initializationError = undefined; const args = [ ...discard.map((env) => ({ kind: EnvironmentChangeKind.remove, environment: env })), @@ -186,8 +227,8 @@ export class PyEnvManager implements EnvironmentManager, Disposable { } } - async get(scope: GetEnvironmentScope): Promise { - const fastResult = await tryFastPathGet({ + async get(scope: GetEnvironmentScope, toolExecution = false): Promise { + const fastResult = toolExecution ? undefined : await tryFastPathGet({ initialized: this._initialized, setInitialized: (deferred) => { this._initialized = deferred; @@ -197,23 +238,13 @@ export class PyEnvManager implements EnvironmentManager, Disposable { getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), getPersistedPath: (fsPath) => getPyenvForWorkspace(fsPath), resolve: (p) => resolvePyenvPath(p, this.nativeFinder, this.api, this), - startBackgroundInit: () => - withProgress({ location: ProgressLocation.Window, title: PyenvStrings.pyenvDiscovering }, async () => { - this.collection = (await refreshPyenv(false, this.nativeFinder, this.api, this)) ?? []; - await this.loadEnvMap(); - this._onDidChangeEnvironments.fire( - this.collection.map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.add, - })), - ); - }), + startBackgroundInit: () => this.initializeDiscovery(), }); if (fastResult) { return fastResult.env; } - await this.initialize(); + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (scope instanceof Uri) { let env = this.fsPathToEnv.get(normalizePath(scope.fsPath)); if (env) { @@ -280,8 +311,8 @@ export class PyEnvManager implements EnvironmentManager, Disposable { } } - async resolve(context: ResolveEnvironmentContext): Promise { - await this.initialize(); + async resolve(context: ResolveEnvironmentContext, toolExecution = false): Promise { + await (toolExecution ? this.initializeDiscovery() : this.initialize()); if (context instanceof Uri) { const env = await resolvePyenvPath(context.fsPath, this.nativeFinder, this.api, this); diff --git a/src/managers/pyenv/pyenvUtils.ts b/src/managers/pyenv/pyenvUtils.ts index 2e49ad0e2..e3cf1e36e 100644 --- a/src/managers/pyenv/pyenvUtils.ts +++ b/src/managers/pyenv/pyenvUtils.ts @@ -9,6 +9,7 @@ import { PythonEnvironmentApi, PythonEnvironmentInfo, } from '../../api'; +import { execProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { traceError, traceInfo } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; @@ -170,6 +171,24 @@ export async function getPyenv(): Promise { } } +/** + * Returns the version reported by the Pyenv executable. + * + * @param pyenv Path to the Pyenv executable. + * @returns The version string, or `undefined` when it cannot be determined. + */ +export async function getPyenvVersion(pyenv: string): Promise { + try { + const { stdout } = await execProcess(`"${pyenv}" --version`); + traceInfo(`Pyenv version output: ${stdout.trim()}`); + const match = stdout.match(/pyenv(?:-win)?\s+(\d+(?:\.\d+)+)/i); + return match?.[1]; + } catch (error) { + traceInfo(`Unable to determine Pyenv version: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } +} + function nativeToPythonEnv( info: NativeEnvInfo, api: PythonEnvironmentApi, diff --git a/src/test/common/environmentPicker.unit.test.ts b/src/test/common/environmentPicker.unit.test.ts index 3b399ffb4..f93ba6714 100644 --- a/src/test/common/environmentPicker.unit.test.ts +++ b/src/test/common/environmentPicker.unit.test.ts @@ -4,13 +4,15 @@ import assert from 'node:assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { Uri } from 'vscode'; +import { CancellationTokenSource, Uri } from 'vscode'; import { PythonEnvironment } from '../../api'; import { Interpreter } from '../../common/localize'; import { pickEnvironment } from '../../common/pickers/environments'; import * as managerPicker from '../../common/pickers/managers'; +import * as pythonPath from '../../common/utils/pythonPath'; import * as windowApis from '../../common/window.apis'; import * as workspaceApis from '../../common/workspace.apis'; +import type { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; import { createMockPythonEnvironment } from '../mocks/pythonEnvironment'; suite('Environment Picker Creation Availability', () => { @@ -39,6 +41,7 @@ suite('Environment Picker Creation Availability', () => { canCreate, ); assert.ok(items.some((item) => item.label === Interpreter.browsePath)); + assert.ok(items.some((item) => item.label === Interpreter.enterInterpreterPath)); return undefined; }); @@ -72,6 +75,106 @@ suite('Environment Picker Creation Availability', () => { }); }); +suite('Environment Picker Interpreter Path Entry', () => { + const interpreter = Uri.file(path.join(process.cwd(), 'project with spaces', '.venv', 'python')); + const environment = createMockPythonEnvironment({ envPath: interpreter.fsPath }); + const managers = [{ getEnvironments: async () => [] }] as unknown as InternalEnvironmentManager[]; + const projectManagers = [{}] as InternalEnvironmentManager[]; + let input: sinon.SinonStub; + let resolve: sinon.SinonStub; + let showError: sinon.SinonStub; + let tokenSource: CancellationTokenSource; + const reporter = { report: sinon.stub() }; + + setup(() => { + tokenSource = new CancellationTokenSource(); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); + sinon + .stub(windowApis, 'showQuickPickWithButtons') + .callsFake(async (items) => items.find((item) => item.label === Interpreter.enterInterpreterPath)); + input = sinon.stub(windowApis, 'showInputBox'); + resolve = sinon.stub(pythonPath, 'handlePythonPath').resolves(environment); + showError = sinon.stub(windowApis, 'showErrorMessage'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task(reporter, tokenSource.token)); + }); + + teardown(() => { + tokenSource.dispose(); + sinon.restore(); + }); + + for (const [name, typedInterpreter] of [ + ['native path', interpreter], + [ + 'uppercase Windows drive letter', + Uri.from({ + scheme: 'file', + path: path.posix.join('/', 'D:', 'project with spaces', '.venv', 'python.exe'), + }), + ], + ] as const) { + test(`resolves a typed path using the project and available managers: ${name}`, async () => { + input.resolves(` ${typedInterpreter.fsPath} `); + + const selected = await pickEnvironment(managers, projectManagers, { projects: [] }); + + assert.strictEqual(selected, environment); + sinon.assert.calledOnce(input); + sinon.assert.calledOnceWithExactly( + resolve, + sinon.match.instanceOf(Uri).and(sinon.match.has('fsPath', typedInterpreter.fsPath)), + managers, + projectManagers, + reporter, + tokenSource.token, + ); + sinon.assert.notCalled(showError); + }); + } + + for (const value of [undefined, '', ' ']) { + test(`does not resolve or select an interpreter for cancelled or empty input: ${JSON.stringify(value)}`, async () => { + input.resolves(value); + + assert.strictEqual(await pickEnvironment(managers, projectManagers, { projects: [] }), undefined); + + sinon.assert.notCalled(resolve); + sinon.assert.notCalled(showError); + }); + } + + test('reports an invalid interpreter without selecting an environment', async () => { + input.resolves(interpreter.fsPath); + resolve.resolves(undefined); + + assert.strictEqual(await pickEnvironment(managers, projectManagers, { projects: [] }), undefined); + + sinon.assert.calledOnce(resolve); + sinon.assert.calledOnce(showError); + assert.ok(showError.firstCall.args[0].includes(interpreter.fsPath)); + }); + + test('still resolves an interpreter selected through Browse', async () => { + (windowApis.showQuickPickWithButtons as sinon.SinonStub).callsFake(async (items) => + items.find((item: { label: string }) => item.label === Interpreter.browsePath), + ); + sinon.stub(windowApis, 'showOpenDialog').resolves([interpreter]); + + assert.strictEqual(await pickEnvironment(managers, projectManagers, { projects: [] }), environment); + + sinon.assert.calledOnceWithExactly( + resolve, + interpreter, + managers, + projectManagers, + reporter, + tokenSource.token, + ); + sinon.assert.notCalled(input); + sinon.assert.notCalled(showError); + }); +}); + /** * Test the logic used in environment pickers to include interpreter paths in descriptions */ diff --git a/src/test/common/inlineScript/block.unit.test.ts b/src/test/common/inlineScript/block.unit.test.ts new file mode 100644 index 000000000..84dab308d --- /dev/null +++ b/src/test/common/inlineScript/block.unit.test.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { findInlineScriptBlock } from '../../../common/inlineScript/block'; +import { getInlineScriptSourceHash } from '../../../common/inlineScript/metadata'; + +suite('Inline script live block', () => { + const block = '# /// script\n# dependencies = ["requests"]\n# ///'; + + test('recognizes valid, empty, incomplete, and malformed blocks without parsing', () => { + for (const text of [block, '# /// script\n# ///', '# /// script', `${block}\n# /// script`, '# /// script \n#bad']) { + const found = findInlineScriptBlock(text); + assert.ok(found, text); + assert.strictEqual(found.start, 0); + assert.ok(found.end >= found.start); + } + }); + + test('does not recognize ordinary files, mentions, or other block types', () => { + for (const text of ['', 'print("hello")', '# mentions # /// script', '# /// pyproject\n# ///', '# /// scripting']) { + assert.strictEqual(findInlineScriptBlock(text), undefined, text); + } + }); + + test('excludes Python code and the closing marker newline from the hash', () => { + const expected = getInlineScriptSourceHash(block); + assert.match(expected!, /^[a-f0-9]{64}$/); + assert.strictEqual(getInlineScriptSourceHash(`${block}\nprint("first")`), expected); + assert.strictEqual(getInlineScriptSourceHash(`${block}\nprint("second")\n`), expected); + assert.strictEqual(getInlineScriptSourceHash(`#!/usr/bin/env python\n\n${block}`), expected); + assert.strictEqual(getInlineScriptSourceHash(`${block}\n# ordinary comment`), expected); + }); + + test('fingerprints raw metadata changes rather than semantic dependency identity', () => { + const expected = getInlineScriptSourceHash(block); + for (const edited of [ + block.replace('requests', 'Requests'), + block.replace(' = ', '='), + block.replace('# dependencies', '# additional comment\n# dependencies'), + block.replace('"requests"', '"requests'), + block.slice(0, block.lastIndexOf('# ///')), + ]) { + assert.notStrictEqual(getInlineScriptSourceHash(edited), expected); + } + }); + + test('keeps the first anchor but withholds the fingerprint for multiple blocks', () => { + const original = findInlineScriptBlock(block)!; + const multiple = findInlineScriptBlock(`${block}\nprint("body")\n${block}`)!; + assert.strictEqual(multiple.start, original.start); + assert.strictEqual(multiple.end, original.end); + assert.strictEqual(getInlineScriptSourceHash(`${block}\nprint("body")\n${block}`), undefined); + }); + + test('preserves the source span across BOM, CRLF, and lone CR', () => { + const expectedHash = getInlineScriptSourceHash(block); + for (const newline of ['\n', '\r\n', '\r']) { + const sourceBlock = block.replace(/\n/g, newline); + const source = `\uFEFF${sourceBlock}${newline}print("body")`; + const found = findInlineScriptBlock(source)!; + assert.strictEqual(found.start, 1); + assert.strictEqual(source.slice(found.start, found.end), sourceBlock); + assert.strictEqual(getInlineScriptSourceHash(source), expectedHash); + } + }); + + test('normalizes mixed line endings for editor and disk fingerprint comparison', () => { + const mixed = '# /// script\r\n# dependencies = ["requests"]\n# ///'; + assert.strictEqual(getInlineScriptSourceHash(mixed), getInlineScriptSourceHash(block)); + }); + + test('recognizes malformed content before a closing marker', () => { + const source = '# /// script\nnot_a_comment\n# ///\nprint("body")'; + const found = findInlineScriptBlock(source)!; + assert.strictEqual(source.slice(found.start, found.end), '# /// script\nnot_a_comment\n# ///'); + }); + + test('includes embedded closing markers followed by further comment content', () => { + const source = '# /// script\n# [tool.example]\n# value = """\n# ///\n# text\n# """\n# ///\nprint("body")'; + const hash = getInlineScriptSourceHash(source); + assert.ok(hash); + assert.notStrictEqual( + getInlineScriptSourceHash(source.replace('# text', '# edited text')), + hash, + ); + }); + + test('does not omit dependencies between embedded closing and opening markers', () => { + const source = [ + '# /// script', + '# note = """', + '# ///', + '# """', + '# dependencies = ["requests"]', + '# other = """', + '# /// script', + '# """', + '# ///', + ].join('\n'); + assert.notStrictEqual( + getInlineScriptSourceHash(source), + getInlineScriptSourceHash(source.replace('requests', 'httpx')), + ); + }); + + test('does not ignore a BOM inserted before a non-leading marker', () => { + const source = `# prefix\n${block}`; + assert.notStrictEqual( + getInlineScriptSourceHash(source), + getInlineScriptSourceHash(source.replace('# /// script', '\uFEFF# /// script')), + ); + }); + + test('withholds a fingerprint when a preceding non-script block consumes the script marker', () => { + assert.ok(getInlineScriptSourceHash(`# note\n${block}`)); + assert.ok(findInlineScriptBlock(`# /// other\n${block}`), 'the setup action still needs an anchor'); + assert.strictEqual(getInlineScriptSourceHash(`# /// other\n${block}`), undefined); + }); + + test('does not fingerprint ignored unfinished metadata examples in the Python body', () => { + const source = `${block}\n\n"""\n# /// script\n# dependencies = ["example"]\n"""\nprint("body")`; + assert.strictEqual(getInlineScriptSourceHash(source), getInlineScriptSourceHash(block)); + assert.strictEqual(getInlineScriptSourceHash(source.replace('"body"', '"changed"')), getInlineScriptSourceHash(block)); + }); + + test('fingerprinting does not parse TOML', () => { + assert.ok(getInlineScriptSourceHash('# /// script\n# dependencies = [\n# ///')); + }); +}); diff --git a/src/test/common/inlineScript/metadata.unit.test.ts b/src/test/common/inlineScript/metadata.unit.test.ts index 7e8e0dd78..713e59233 100644 --- a/src/test/common/inlineScript/metadata.unit.test.ts +++ b/src/test/common/inlineScript/metadata.unit.test.ts @@ -9,6 +9,7 @@ import * as sinon from 'sinon'; import { Uri } from 'vscode'; import { InlineScriptMetadata, + getInlineScriptSourceHash, MAX_HEADER_BYTES, matchesPythonVersion, readInlineScriptMetadata, @@ -93,6 +94,62 @@ suite('inlineScriptMetadata', () => { assert.deepStrictEqual(md.tool, { mybuild: { extra: 'thing' } }); }); + test('fingerprints dependencies surrounded by marker text inside valid TOML strings', () => { + const text = script([ + '# /// script', + '# note = """', + '# ///', + '# """', + '# dependencies = ["requests"]', + '# other = """', + '# /// script', + '# """', + '# ///', + ]); + const original = readInlineScriptMetadata(text); + const changed = readInlineScriptMetadata(text.replace('requests', 'httpx')); + assert.ok(original?.sourceHash); + assert.ok(changed?.sourceHash); + assert.deepStrictEqual(original.dependencies, ['requests']); + assert.deepStrictEqual(changed.dependencies, ['httpx']); + assert.notStrictEqual(original.sourceHash, changed.sourceHash); + }); + + test('fingerprints every non-newline character of valid metadata with embedded markers', () => { + const fixtures = [ + script(['# /// script', '# requires-python = ">=3.11"', '# dependencies = ["requests"]', '# ///']), + script([ + '# /// script', + '# note = """', + '# ///', + '# """', + '# dependencies = ["requests"]', + '# other = """', + '# /// script', + '# /// script', + '# content', + '# """', + '# ///', + ]), + ]; + for (const source of fixtures) { + const metadata = readInlineScriptMetadata(source); + assert.ok(metadata?.sourceHash); + for (let offset = metadata.sourceRange!.start; offset < metadata.sourceRange!.end; offset += 1) { + if (source[offset] === '\n' || source[offset] === '\r') { + continue; + } + const replacement = source[offset] === 'x' ? 'y' : 'x'; + const changed = source.slice(0, offset) + replacement + source.slice(offset + 1); + assert.notStrictEqual( + getInlineScriptSourceHash(changed), + metadata.sourceHash, + `Fingerprint omitted metadata character at ${offset}`, + ); + } + } + }); + test('multiple `script` blocks returns undefined and logs a warning', () => { const text = script([ '# /// script', @@ -416,6 +473,20 @@ suite('inlineScriptMetadata', () => { assert.strictEqual(md, undefined); }); + test('strict tool reads reject invalid metadata while public reads keep their existing behavior', async () => { + const uri = Uri.file(path.join(tmpDir, 'invalid.py')); + await fs.writeFile(uri.fsPath, script(['# /// script', '# dependencies = [', '# ///'])); + assert.strictEqual(await readInlineScriptMetadataFromFile(uri), undefined); + await assert.rejects(readInlineScriptMetadataFromFile(uri, true), /PEP 723/); + }); + + test('strict tool reads surface I/O errors but accept an ordinary script without metadata', async () => { + const uri = Uri.file(path.join(tmpDir, 'plain.py')); + await assert.rejects(readInlineScriptMetadataFromFile(uri, true), /ENOENT/); + await fs.writeFile(uri.fsPath, 'print("ordinary script")\n'); + assert.strictEqual(await readInlineScriptMetadataFromFile(uri, true), undefined); + }); + test('block past MAX_HEADER_BYTES boundary is NOT found (cap is enforced)', async () => { const filePath = path.join(tmpDir, 'big.py'); // Pad with a comment that fills more than MAX_HEADER_BYTES, diff --git a/src/test/common/inlineScript/routingRegistry.unit.test.ts b/src/test/common/inlineScript/routingRegistry.unit.test.ts index c05f907bb..0e353afd1 100644 --- a/src/test/common/inlineScript/routingRegistry.unit.test.ts +++ b/src/test/common/inlineScript/routingRegistry.unit.test.ts @@ -15,6 +15,21 @@ const METADATA = { }; suite('InlineScriptRoutingRegistry', () => { + test('publishes interpreter versions only for validated associations', () => { + const registry = new InlineScriptRoutingRegistry(); + const uri = Uri.joinPath(Uri.file(process.cwd()), 'script.py'); + const versions: (string | undefined)[] = []; + registry.onDidChangeEnvironmentVersion(() => versions.push(registry.getEnvironmentVersion(uri))); + registry.setMetadata(uri, METADATA); + registry.setValidatedAssociation(uri, true, '3.12.4'); + registry.setValidatedAssociation(uri, true, '3.12.4'); + registry.setValidatedAssociation(uri, true, '3.13.1'); + registry.setValidatedAssociation(uri, false); + assert.deepStrictEqual(versions, ['3.12.4', '3.13.1', undefined]); + assert.strictEqual(registry.getEnvironmentVersion(uri), undefined); + registry.dispose(); + }); + test('temporary unavailability preserves routing and notifies only on availability changes', () => { const registry = new InlineScriptRoutingRegistry(); const uri = Uri.joinPath(Uri.file(process.cwd()), 'script.py'); @@ -84,6 +99,29 @@ suite('InlineScriptRoutingRegistry', () => { registry.dispose(); }); + test('temporarily suppresses routing while live metadata differs and restores it on Undo', () => { + const registry = new InlineScriptRoutingRegistry(); + const uri = Uri.joinPath(Uri.file(process.cwd()), 'script.py'); + const routeabilityEvents: boolean[] = []; + registry.onDidChangeRouteability((event) => routeabilityEvents.push(event.routeable)); + registry.setMetadata(uri, { ...METADATA, sourceHash: 'saved-block' }); + registry.setValidatedAssociation(uri, true, '3.12.4'); + + registry.setLiveMetadataMatchesSaved(uri, false); + + assert.strictEqual(registry.shouldRoute(uri), false); + assert.strictEqual(registry.hasValidatedAssociation(uri), true); + assert.strictEqual(registry.getMetadata(uri)?.sourceHash, 'saved-block'); + assert.strictEqual(registry.getEnvironmentVersion(uri), undefined); + + registry.setLiveMetadataMatchesSaved(uri, true); + + assert.strictEqual(registry.shouldRoute(uri), true); + assert.strictEqual(registry.getEnvironmentVersion(uri), '3.12.4'); + assert.deepStrictEqual(routeabilityEvents, [true, false, true]); + registry.dispose(); + }); + test('keeps metadata revisions monotonic after an empty state is removed', () => { const registry = new InlineScriptRoutingRegistry(); const uri = Uri.file('/workspace/script.py'); diff --git a/src/test/common/pythonPath.unit.test.ts b/src/test/common/pythonPath.unit.test.ts index bc03dbead..9e5202b5b 100644 --- a/src/test/common/pythonPath.unit.test.ts +++ b/src/test/common/pythonPath.unit.test.ts @@ -6,6 +6,7 @@ import * as sinon from 'sinon'; import { CancellationTokenSource, Uri } from 'vscode'; import { PythonEnvironment } from '../../api'; import { handlePythonPath } from '../../common/utils/pythonPath'; +import * as logging from '../../common/logging'; import type { InternalEnvironmentManager } from '../../managers/common/registeredManagers'; function createMockManager( @@ -40,6 +41,21 @@ suite('handlePythonPath', () => { sinon.restore(); }); + test('tool resolution explicitly skips unsupported managers without invoking their public resolver', async () => { + const unsupported = createMockManager('other.extension:manager', 'Unsupported'); + const expected = createMockEnv('ms-python.python:venv'); + const supported = createMockManager('ms-python.python:venv', 'Venv'); + const resolve = sinon.stub().resolves(expected); + Object.defineProperty(supported, 'tools', { value: { resolve } }); + const logged = sinon.stub(logging, 'traceVerbose'); + const result = await handlePythonPath(testUri, [supported], [unsupported], undefined, undefined, true); + assert.strictEqual(result, expected); + assert.ok(resolve.calledOnceWithExactly(testUri)); + assert.ok((unsupported.resolve as sinon.SinonStub).notCalled); + assert.ok((supported.resolve as sinon.SinonStub).notCalled); + assert.ok(logged.calledWithMatch(/Skipping other.extension:manager.*non-interactive/)); + }); + test('returns undefined when no managers can resolve the path', async () => { const manager1 = createMockManager('ms-python.python:venv', 'Venv'); const manager2 = createMockManager('ms-python.python:conda', 'Conda'); diff --git a/src/test/diagnostics.ts b/src/test/diagnostics.ts new file mode 100644 index 000000000..66be8842e --- /dev/null +++ b/src/test/diagnostics.ts @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { version } from 'vscode'; + +/** Record that the extension-host test runner started, before loading test suites. */ +export function mochaGlobalSetup(): void { + const directory = process.env.VSCODE_TEST_DIAGNOSTICS_DIR; + if (directory) { + fs.writeFileSync( + path.join(directory, 'test-runner-started.json'), + JSON.stringify({ vscodeVersion: version, extensionHostArgs: process.argv }, undefined, 2), + ); + } +} diff --git a/src/test/diagnostics.unit.test.ts b/src/test/diagnostics.unit.test.ts new file mode 100644 index 000000000..b9ade804c --- /dev/null +++ b/src/test/diagnostics.unit.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert'; +import fs from 'fs'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { version } from 'vscode'; +import { mochaGlobalSetup } from './diagnostics'; + +suite('Test runner diagnostics', () => { + let write: sinon.SinonStub; + const directory = path.resolve('test diagnostics'); + + setup(() => { + sinon.stub(process, 'env').value({ ...process.env, VSCODE_TEST_DIAGNOSTICS_DIR: directory }); + write = sinon.stub(fs, 'writeFileSync'); + }); + + teardown(() => sinon.restore()); + + test('records the test-runner marker and runtime details', () => { + mochaGlobalSetup(); + + sinon.assert.calledOnceWithExactly( + write, + path.join(directory, 'test-runner-started.json'), + JSON.stringify({ vscodeVersion: version, extensionHostArgs: process.argv }, undefined, 2), + ); + }); + + test('does not write diagnostics when disabled', () => { + delete process.env.VSCODE_TEST_DIAGNOSTICS_DIR; + + mochaGlobalSetup(); + + sinon.assert.notCalled(write); + }); + + test('does not hide diagnostic write failures', () => { + const error = new Error('Cannot write diagnostics'); + write.throws(error); + + assert.throws(() => mochaGlobalSetup(), (actual) => actual === error); + }); +}); diff --git a/src/test/extensionApi.unit.test.ts b/src/test/extensionApi.unit.test.ts index 62459fbc3..f47ee1896 100644 --- a/src/test/extensionApi.unit.test.ts +++ b/src/test/extensionApi.unit.test.ts @@ -35,6 +35,8 @@ suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { mockTerminalManager, mockEnvVarManager, ); + assert.strictEqual(api.__pythonTools.version, 1); + assert.strictEqual(typeof api.getEnvironment, 'function'); let firedEventPayload: unknown = null; api.onDidChangePythonProjects((event: unknown) => { diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 64808d382..1fe9e997d 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -2,14 +2,17 @@ import * as assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { Memento, Terminal, Uri } from 'vscode'; +import { CancellationTokenSource, Memento, Terminal, Uri } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; import { INLINE_SCRIPT_ENVS_KEY, INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; +import { Interpreter } from '../../common/localize'; import * as persistentState from '../../common/persistentState'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; +import * as pythonPath from '../../common/utils/pythonPath'; import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; import { clearEnvironmentCachesCommand, clearScriptEnvironmentCacheCommand, @@ -20,6 +23,7 @@ import { revealEnvInManagerView, runInDedicatedTerminalCommand, runInTerminalCommand, + setEnvironmentCommand, } from '../../features/envCommands'; import * as settingHelpers from '../../features/settings/settingHelpers'; import * as terminalRunner from '../../features/terminal/runInTerminal'; @@ -100,6 +104,64 @@ suite('Environment removal command ownership', () => { }); }); +suite('Project View Interpreter Path Selection', () => { + teardown(() => { + sinon.restore(); + }); + + for (const outcome of ['resolved', 'cancelled', 'invalid']) { + test(`keeps the project scope when interpreter path entry is ${outcome}`, async () => { + const project: PythonProject = { + uri: Uri.file(path.join(process.cwd(), 'project')), + name: 'project', + }; + const interpreter = Uri.file(path.join(project.uri.fsPath, '.venv', 'python')); + const environment = createMockPythonEnvironment({ envPath: interpreter.fsPath }); + const manager = { + get: sinon.stub().resolves(undefined), + getEnvironments: sinon.stub().resolves([]), + }; + const setEnvironments = sinon.stub().resolves(); + const em = { + managers: [manager], + getProjectEnvManagers: sinon.stub().returns([manager]), + getEnvironment: sinon.stub().resolves(undefined), + setEnvironments, + } as unknown as EnvironmentManagers; + const pm = { + getProjects: sinon.stub().returns([project]), + } as unknown as PythonProjectManager; + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([{ ...project, index: 0 }]); + sinon + .stub(windowApis, 'showQuickPickWithButtons') + .callsFake(async (items) => items.find((item) => item.label === Interpreter.enterInterpreterPath)); + sinon.stub(windowApis, 'showInputBox').resolves(outcome === 'cancelled' ? undefined : interpreter.fsPath); + const resolve = sinon + .stub(pythonPath, 'handlePythonPath') + .resolves(outcome === 'resolved' ? environment : undefined); + sinon.stub(windowApis, 'showErrorMessage'); + const tokenSource = new CancellationTokenSource(); + sinon + .stub(windowApis, 'withProgress') + .callsFake(async (_options, task) => task({ report: sinon.stub() }, tokenSource.token)); + + try { + await setEnvironmentCommand(new ProjectItem(project), em, pm); + + if (outcome === 'resolved') { + sinon.assert.calledOnceWithExactly(setEnvironments, [project.uri], environment); + assert.strictEqual(resolve.firstCall.args[0].fsPath, interpreter.fsPath); + assert.deepStrictEqual(resolve.firstCall.args[2], [manager]); + } else { + sinon.assert.notCalled(setEnvironments); + } + } finally { + tokenSource.dispose(); + } + }); + } +}); + suite('Create Any Environment Command Tests', () => { let em: typeMoq.IMock; let pm: typeMoq.IMock; diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index 63db6773f..3879a2fce 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; +import { explain as parsePep440Version } from '@renovatebot/pep440'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import { PythonEnvironment } from '../../api'; @@ -76,11 +77,16 @@ suite('PythonEnvironmentManagers - getEnvironment', () => { /** * Registers a fake environment manager that returns predefined environments. */ - function registerFakeManager(managerId: string, getStub: sinon.SinonStub): void { + function registerFakeManager( + managerId: string, + getStub: sinon.SinonStub, + getVersion?: sinon.SinonStub, + ): void { const fakeManager = { name: managerId.split(':')[1], displayName: managerId, preferredPackageManagerId: 'ms-python.python:pip', + getVersion, get: getStub, set: sandbox.stub().resolves(), resolve: sandbox.stub().resolves(undefined), @@ -145,6 +151,25 @@ suite('PythonEnvironmentManagers - getEnvironment', () => { assert.ok(getStub.calledOnce, 'Should delegate to manager.get()'); }); + test('should delegate getVersion to the registered manager', async () => { + const version = parsePep440Version('1.2.3'); + const getVersion = sandbox.stub().resolves(version); + registerFakeManager('ms-python.python:system', sandbox.stub().resolves(env311), getVersion); + + const result = await envManagers.getEnvironmentManager('ms-python.python:system')?.getVersion(); + + assert.deepStrictEqual(result, version); + assert.ok(getVersion.calledOnce, 'Should delegate to manager.getVersion()'); + }); + + test('should return undefined when the registered manager does not provide getVersion', async () => { + registerFakeManager('ms-python.python:system', sandbox.stub().resolves(env311)); + + const result = await envManagers.getEnvironmentManager('ms-python.python:system')?.getVersion(); + + assert.strictEqual(result, undefined); + }); + test('should return undefined when no managers are registered', async () => { // No managers registered at all — size === 0 guard fires const result = await envManagers.getEnvironment(Uri.file('/some/unknown/path')); diff --git a/src/test/features/execution/envVariableManager.unit.test.ts b/src/test/features/execution/envVariableManager.unit.test.ts new file mode 100644 index 000000000..142607572 --- /dev/null +++ b/src/test/features/execution/envVariableManager.unit.test.ts @@ -0,0 +1,217 @@ +import * as assert from 'assert'; +import fsapi from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import * as envVarUtils from '../../../features/execution/envVarUtils'; +import { PythonEnvVariableManager } from '../../../features/execution/envVariableManager'; +import type { PythonProjectManager } from '../../../features/projectManager'; + +type StatResult = 'file' | 'directory' | NodeJS.ErrnoException; + +function errnoError(code: string): NodeJS.ErrnoException { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +} + +function pathKey(filePath: string): string { + const normalizedPath = path.normalize(filePath); + return process.platform === 'win32' ? normalizedPath.toLowerCase() : normalizedPath; +} + +suite('PythonEnvVariableManager.getEnvironmentVariables', () => { + let sandbox: sinon.SinonSandbox; + let parseEnvFileStub: sinon.SinonStub; + let configuredEnvFile: string | undefined; + let manager: PythonEnvVariableManager; + + const projectRoot = path.resolve('/workspace/project'); + const projectUri = Uri.file(projectRoot); + const projectEnvFile = path.normalize(path.join(projectRoot, '.env')); + const configuredFile = path.normalize(path.resolve('/config/custom.env')); + + function parsedPaths(): string[] { + return parseEnvFileStub.getCalls().map((c) => pathKey((c.args[0] as Uri).fsPath)); + } + + function stubStat(results: Map): void { + const normalizedResults = new Map([...results].map(([p, result]) => [pathKey(p), result])); + sandbox.stub(fsapi, 'stat').callsFake((async (p: string) => { + const result = normalizedResults.get(pathKey(p)) ?? errnoError('ENOENT'); + if (result === 'file' || result === 'directory') { + return { isFile: () => result === 'file' } as fsapi.Stats; + } + throw result; + }) as unknown as typeof fsapi.stat); + } + + setup(() => { + sandbox = sinon.createSandbox(); + configuredEnvFile = undefined; + + const mockWatcher = { + onDidCreate: () => ({ dispose: () => {} }), + onDidChange: () => ({ dispose: () => {} }), + onDidDelete: () => ({ dispose: () => {} }), + dispose: () => {}, + }; + sandbox.stub(workspaceApis, 'createFileSystemWatcher').returns(mockWatcher as never); + sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(undefined); + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); + sandbox.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string) => (key === 'envFile' ? configuredEnvFile : undefined), + } as never); + + parseEnvFileStub = sandbox.stub(envVarUtils, 'parseEnvFile'); + parseEnvFileStub.callsFake(async (uri: Uri) => { + if (pathKey(uri.fsPath) === pathKey(projectEnvFile)) { + return { PROJECT_VAR: 'project', SHARED: 'from-project' }; + } + return { CONFIG_VAR: 'config', SHARED: 'from-config' }; + }); + + const pm = { + get: () => ({ name: 'project', uri: projectUri }), + } as unknown as PythonProjectManager; + manager = new PythonEnvVariableManager(pm); + }); + + teardown(() => { + manager.dispose(); + sandbox.restore(); + }); + + test('project .env directory with missing configured file does not throw and leaves base unchanged', async () => { + configuredEnvFile = configuredFile; + stubStat(new Map([[projectEnvFile, 'directory']])); + const base = { BASE: 'base' }; + + const env = await manager.getEnvironmentVariables(projectUri, undefined, base); + + assert.deepStrictEqual(env, { BASE: 'base' }); + assert.deepStrictEqual(parsedPaths(), []); + }); + + test('configured env file that is a directory is skipped while project .env still loads', async () => { + configuredEnvFile = configuredFile; + stubStat( + new Map([ + [configuredFile, 'directory'], + [projectEnvFile, 'file'], + ]), + ); + + const env = await manager.getEnvironmentVariables(projectUri, undefined, { BASE: 'base' }); + + assert.deepStrictEqual(parsedPaths(), [pathKey(projectEnvFile)]); + assert.strictEqual(env.PROJECT_VAR, 'project'); + assert.strictEqual(env.CONFIG_VAR, undefined); + }); + + test('valid configured file is loaded when project .env is a directory', async () => { + configuredEnvFile = configuredFile; + stubStat( + new Map([ + [configuredFile, 'file'], + [projectEnvFile, 'directory'], + ]), + ); + + const env = await manager.getEnvironmentVariables(projectUri, undefined, { BASE: 'base' }); + + assert.deepStrictEqual(parsedPaths(), [pathKey(configuredFile)]); + assert.strictEqual(env.CONFIG_VAR, 'config'); + assert.strictEqual(env.SHARED, 'from-config'); + }); + + test('when both are regular files, configured file is applied before project .env', async () => { + configuredEnvFile = configuredFile; + stubStat( + new Map([ + [configuredFile, 'file'], + [projectEnvFile, 'file'], + ]), + ); + + const env = await manager.getEnvironmentVariables(projectUri, undefined, { BASE: 'base' }); + + assert.deepStrictEqual(parsedPaths(), [pathKey(configuredFile), pathKey(projectEnvFile)]); + assert.strictEqual(env.SHARED, 'from-project'); + assert.strictEqual(env.PROJECT_VAR, 'project'); + }); + + test('missing paths and ENOTDIR are skipped without parsing', async () => { + configuredEnvFile = configuredFile; + stubStat(new Map([[configuredFile, errnoError('ENOTDIR')]])); + const base = { BASE: 'base' }; + + const env = await manager.getEnvironmentVariables(projectUri, undefined, base); + + assert.deepStrictEqual(env, { BASE: 'base' }); + assert.deepStrictEqual(parsedPaths(), []); + }); + + test('unexpected stat errors such as EACCES are propagated', async () => { + stubStat(new Map([[projectEnvFile, errnoError('EACCES')]])); + + await assert.rejects( + manager.getEnvironmentVariables(projectUri, undefined, { BASE: 'base' }), + (err: NodeJS.ErrnoException) => err.code === 'EACCES', + ); + assert.deepStrictEqual(parsedPaths(), []); + }); + + suite('symlinks', () => { + let tmpDir: string; + + setup(async () => { + tmpDir = await fsapi.mkdtemp(path.join(os.tmpdir(), 'envvarmgr-')); + }); + + teardown(async () => { + await fsapi.remove(tmpDir); + }); + + async function trySymlink(target: string, link: string, type: 'file' | 'dir'): Promise { + try { + await fsapi.symlink(target, link, type); + return true; + } catch { + return false; + } + } + + test('symlink to a regular file is accepted', async function () { + const target = path.join(tmpDir, 'real.env'); + const link = path.join(tmpDir, 'link.env'); + await fsapi.writeFile(target, 'CONFIG_VAR=config\n'); + if (!(await trySymlink(target, link, 'file'))) { + this.skip(); + } + configuredEnvFile = link; + + const env = await manager.getEnvironmentVariables(projectUri, undefined, { BASE: 'base' }); + + assert.deepStrictEqual(parsedPaths(), [pathKey(link)]); + assert.strictEqual(env.CONFIG_VAR, 'config'); + }); + + test('symlink to a directory is skipped', async function () { + const target = path.join(tmpDir, 'realdir'); + const link = path.join(tmpDir, 'linkdir'); + await fsapi.mkdir(target); + if (!(await trySymlink(target, link, 'dir'))) { + this.skip(); + } + configuredEnvFile = link; + + const env = await manager.getEnvironmentVariables(projectUri, undefined, { BASE: 'base' }); + + assert.deepStrictEqual(env, { BASE: 'base' }); + assert.deepStrictEqual(parsedPaths(), []); + }); + }); +}); diff --git a/src/test/features/inlineScript/codeLens.unit.test.ts b/src/test/features/inlineScript/codeLens.unit.test.ts index acae445f0..a0cb35e6c 100644 --- a/src/test/features/inlineScript/codeLens.unit.test.ts +++ b/src/test/features/inlineScript/codeLens.unit.test.ts @@ -2,36 +2,37 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; -import { Position, TextDocument, Uri } from 'vscode'; -import { InlineScriptMetadata } from '../../../common/inlineScript/metadata'; +import { + Disposable, + TextDocumentChangeEvent, + TextDocumentContentChangeEvent, + Uri, +} from 'vscode'; +import { MAX_HEADER_BYTES, readInlineScriptMetadata } from '../../../common/inlineScript/metadata'; import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; -import { InlineScriptCodeLensProvider, READY_CONFIRMATION_TIMEOUT_MS } from '../../../features/inlineScript/codeLens'; +import * as wapi from '../../../common/workspace.apis'; +import { InlineScriptCodeLensProvider } from '../../../features/inlineScript/codeLens'; +import { MockDocument } from '../../mocks/mockDocument'; const SETUP_COMMAND = 'python-envs.setupInlineScriptEnv'; - -function makeMetadata(): InlineScriptMetadata { - return { - dependencies: ['requests'], - range: { start: 0, end: 24 }, - sourceRange: { start: 0, end: 24 }, - }; -} - -function makeDocument(uri: Uri, isDirty = false): TextDocument { - return { - uri, - isDirty, - positionAt: (offset: number) => new Position(0, offset), - } as unknown as TextDocument; -} +const SCRIPT = '# /// script\n# dependencies = ["requests"]\n# ///\n\nprint("hello")\n'; suite('Inline script CodeLens provider', () => { - const scriptUri = Uri.file('/workspace/app.py'); + const scriptUri = Uri.file(path.join(process.cwd(), 'lens-tests', 'app.py')); let routing: InlineScriptRoutingRegistry; let provider: InlineScriptCodeLensProvider; + let changeListener: ((event: TextDocumentChangeEvent) => void) | undefined; setup(() => { + changeListener = undefined; + sinon.stub(wapi, 'onDidChangeTextDocument').callsFake((listener) => { + changeListener = listener; + return new Disposable(() => { + changeListener = undefined; + }); + }); routing = new InlineScriptRoutingRegistry(); provider = new InlineScriptCodeLensProvider(routing, SETUP_COMMAND); }); @@ -39,130 +40,279 @@ suite('Inline script CodeLens provider', () => { teardown(() => { provider.dispose(); routing.dispose(); + sinon.restore(); }); - test('shows no CodeLens when the file has no saved inline metadata', () => { - const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); - assert.strictEqual(lenses.length, 0); + function document(text = SCRIPT, dirty = false, uri = scriptUri): MockDocument { + const doc = new MockDocument(text, uri.fsPath, async () => true); + sinon.stub(doc, 'uri').get(() => uri); + sinon.stub(doc, 'isDirty').get(() => dirty); + return doc; + } + + function ready(text = SCRIPT, version: string | undefined = '3.12.4', uri = scriptUri): void { + const metadata = readInlineScriptMetadata(text); + assert.ok(metadata); + routing.setMetadata(uri, metadata); + routing.setValidatedAssociation(uri, true, version); + } + + function lens(text = SCRIPT, dirty = false, uri = scriptUri) { + const lenses = provider.provideCodeLenses(document(text, dirty, uri), {} as never); + assert.strictEqual(lenses.length, 1); + return lenses[0]; + } + + function fireChange( + text: string, + rangeOffset: number, + rangeLength: number, + insertedText: string, + uri = scriptUri, + ): void { + assert.ok(changeListener, 'document change listener should be registered'); + const contentChange: TextDocumentContentChangeEvent = { + range: undefined as never, + rangeOffset, + rangeLength, + text: insertedText, + }; + changeListener({ + document: document(text, true, uri), + contentChanges: [contentChange], + reason: undefined, + }); + } + + test('does not decorate ordinary Python files', () => { + assert.deepStrictEqual(provider.provideCodeLenses(document('print("hello")'), {} as never), []); }); - test('shows a setup CodeLens when metadata exists but no environment is associated', () => { - routing.setMetadata(scriptUri, makeMetadata()); + test('refreshes when a dirty ordinary file gains its first script block', () => { + assert.deepStrictEqual(provider.provideCodeLenses(document('print("hello")'), {} as never), []); + const changed = sinon.spy(); + provider.onDidChangeCodeLenses(changed); - const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); + fireChange(SCRIPT, 0, 0, SCRIPT); - assert.strictEqual(lenses.length, 1); - assert.strictEqual(lenses[0].command?.command, SETUP_COMMAND); - assert.deepStrictEqual(lenses[0].command?.arguments, [scriptUri]); + sinon.assert.calledOnce(changed); + assert.strictEqual(lens(SCRIPT, true).command?.command, SETUP_COMMAND); }); - test('shows no CodeLens while the document has unsaved changes', () => { - routing.setMetadata(scriptUri, makeMetadata()); + test('refreshes when dirty inline metadata changes before routing catches up', () => { + ready(); + assert.strictEqual(lens().command?.command, ''); + const changed = sinon.spy(); + provider.onDidChangeCodeLenses(changed); + const edited = SCRIPT.replace('requests', 'httpx'); - const lenses = provider.provideCodeLenses(makeDocument(scriptUri, true), {} as never); + fireChange(edited, SCRIPT.indexOf('requests'), 'requests'.length, 'httpx'); - assert.strictEqual(lenses.length, 0); + sinon.assert.calledOnce(changed); + assert.strictEqual(lens(edited, true).command?.command, SETUP_COMMAND); }); - test('hides the CodeLens once a validated association makes the script routeable', () => { - routing.setMetadata(scriptUri, makeMetadata()); - routing.setValidatedAssociation(scriptUri, true); - assert.strictEqual(routing.shouldRoute(scriptUri), true); + test('does not refresh for a body-only edit after the inline block', () => { + ready(); + assert.strictEqual(lens().command?.command, ''); + const changed = sinon.spy(); + provider.onDidChangeCodeLenses(changed); + const edited = SCRIPT.replace('hello', 'changed body'); - const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); + fireChange(edited, SCRIPT.indexOf('hello'), 'hello'.length, 'changed body'); - assert.strictEqual(lenses.length, 0); + sinon.assert.notCalled(changed); }); - test('offers setup while a selected environment is temporarily unavailable and hides it on recovery', () => { - routing.setMetadata(scriptUri, makeMetadata()); - routing.setValidatedAssociation(scriptUri, true); - let refreshes = 0; - const subscription = provider.onDidChangeCodeLenses(() => { - refreshes += 1; - }); + test('refreshes when a dirty script block is removed', () => { + assert.strictEqual(lens(SCRIPT, true).command?.command, SETUP_COMMAND); + const changed = sinon.spy(); + provider.onDidChangeCodeLenses(changed); + const plain = 'print("hello")\n'; - routing.setEnvironmentUnavailable(scriptUri, true); - const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); - assert.strictEqual(lenses.length, 1); - assert.strictEqual(lenses[0].command?.command, SETUP_COMMAND); - assert.strictEqual(routing.shouldRoute(scriptUri), true); - routing.setEnvironmentUnavailable(scriptUri, false); + fireChange(plain, 0, SCRIPT.length, plain); - assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0); - assert.strictEqual(refreshes, 2); - subscription.dispose(); + sinon.assert.calledOnce(changed); + assert.deepStrictEqual(provider.provideCodeLenses(document(plain, true), {} as never), []); }); - test('refreshes CodeLenses when routing state changes', () => { - let fireCount = 0; - const sub = provider.onDidChangeCodeLenses(() => (fireCount += 1)); + test('offers setup for a newly typed block before detection or saving', () => { + const result = lens(SCRIPT, true); + assert.strictEqual(result.command?.command, SETUP_COMMAND); + assert.deepStrictEqual(result.command?.arguments, [scriptUri]); + assert.strictEqual(routing.getMetadata(scriptUri), undefined, 'presentation must not seed saved routing'); + }); - routing.setMetadata(scriptUri, makeMetadata()); - routing.setValidatedAssociation(scriptUri, true); + for (const text of [ + '# /// script', + '# /// script\n# dependencies = [', + '# /// script\n# dependencies = ["requests",]\nnot_a_comment\n# ///', + '# /// script\n# dependencies = 1\n# ///', + '# /// script \n# dependencies = []\n# /// ', + ' # /// script\n# broken TOML\n# ///', + ]) { + test(`keeps setup available for malformed metadata: ${JSON.stringify(text)}`, () => { + assert.strictEqual(lens(text, true).command?.command, SETUP_COMMAND); + assert.strictEqual(lens(text).command?.command, SETUP_COMMAND); + }); + } - sub.dispose(); - assert.ok(fireCount >= 1, 'onDidChangeCodeLenses should fire when routing state changes'); + test('always shows the ready interpreter for a validated unchanged block', () => { + ready(); + assert.strictEqual(lens().command?.title, 'Script environment ready (Python 3.12.4)'); + assert.strictEqual(lens().command?.command, ''); }); - suite('post-setup confirmation', () => { - let clock: sinon.SinonFakeTimers; + test('does not expire the ready label', () => { + const clock = sinon.useFakeTimers(); + ready(); + clock.tick(60_000); + assert.strictEqual(lens().command?.title, 'Script environment ready (Python 3.12.4)'); + assert.strictEqual(clock.countTimers(), 0); + }); - setup(() => { - clock = sinon.useFakeTimers(); - routing.setMetadata(scriptUri, makeMetadata()); - routing.setValidatedAssociation(scriptUri, true); - }); + test('shows the ready label when the association was restored before the provider was created', () => { + provider.dispose(); + ready(SCRIPT, '3.13.2.final.0'); + provider = new InlineScriptCodeLensProvider(routing, SETUP_COMMAND); + assert.strictEqual(lens().command?.title, 'Script environment ready (Python 3.13.2)'); + }); - teardown(() => clock.restore()); + test('omits an unknown interpreter version without hiding the ready label', () => { + ready(SCRIPT, ''); + assert.strictEqual(lens().command?.title, 'Script environment ready'); + }); - test('replaces the hidden setup lens with a non-clickable confirmation naming the version', () => { - provider.noteEnvironmentReady(scriptUri, '3.12.4'); + test('keeps ready visible during unsaved body-only edits', () => { + ready(); + assert.strictEqual(lens(SCRIPT.replace('hello', 'changed body'), true).command?.command, ''); + }); - const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); + test('offers setup immediately for raw block edits even before routing catches up', () => { + ready(); + assert.strictEqual(lens(SCRIPT.replace('requests', 'Requests'), true).command?.command, SETUP_COMMAND); + assert.strictEqual(routing.shouldRoute(scriptUri), true, 'the lens must not change interpreter routing'); + }); - assert.strictEqual(lenses.length, 1); - assert.strictEqual(lenses[0].command?.title, 'Script environment ready (Python 3.12.4)'); - assert.strictEqual(lenses[0].command?.command, '', 'the confirmation must not be clickable'); - }); + test('keeps setup visible after the detector clears dirty metadata', () => { + ready(); + routing.clearMetadata(scriptUri); + routing.setValidatedAssociation(scriptUri, false); + assert.strictEqual(lens(SCRIPT.replace('"requests"', '"requests'), true).command?.command, SETUP_COMMAND); + }); - test('omits the version when none was resolved', () => { - provider.noteEnvironmentReady(scriptUri, undefined); + test('returns to ready after unchanged requirements are saved and revalidated', () => { + ready(); + const edited = SCRIPT.replace('requests', 'Requests'); + assert.strictEqual(lens(edited, true).command?.command, SETUP_COMMAND); + ready(edited); + assert.strictEqual(lens(edited).command?.command, ''); + }); - const lenses = provider.provideCodeLenses(makeDocument(scriptUri), {} as never); + test('does not show ready for an additional unsaved script block', () => { + ready(); + assert.strictEqual(lens(`${SCRIPT}\n# /// script\n# ///`, true).command?.command, SETUP_COMMAND); + }); - assert.strictEqual(lenses.length, 1); - assert.strictEqual(lenses[0].command?.title, 'Script environment ready'); + for (const invalid of [ + `${SCRIPT}\n# /// script\n#bad`, + SCRIPT.replace('["requests"]', '["requests", ""]'), + SCRIPT.replace('# dependencies', '# requires-python = "invalid"\n# dependencies'), + ]) { + test(`keeps setup after malformed metadata is saved over a ready association: ${JSON.stringify(invalid)}`, () => { + ready(); + const parsed = readInlineScriptMetadata(invalid); + assert.ok(parsed, 'the tolerant parser intentionally retains usable metadata'); + routing.setMetadata(scriptUri, parsed); + routing.setValidatedAssociation(scriptUri, true, '3.12.4'); + assert.strictEqual(lens(invalid).command?.command, SETUP_COMMAND); }); + } - test('expires on its own and refreshes so the lens disappears', () => { - provider.noteEnvironmentReady(scriptUri, '3.12.4'); - let fireCount = 0; - const sub = provider.onDidChangeCodeLenses(() => (fireCount += 1)); + test('shows ready when the editor normalizes saved mixed line endings', () => { + const disk = '# /// script\r\n# dependencies = ["requests"]\n# ///\rprint("hello")'; + ready(disk); + assert.strictEqual(lens(disk.replace(/\r\n?/g, '\n')).command?.command, ''); + }); - clock.tick(READY_CONFIRMATION_TIMEOUT_MS + 1); - sub.dispose(); + test('offers setup if a preceding non-script opener hides the previously validated block', () => { + const original = `# note\n${SCRIPT}`; + ready(original); + assert.strictEqual(lens(original.replace('# note', '# /// other'), true).command?.command, SETUP_COMMAND); + }); - assert.strictEqual(fireCount, 1, 'expiry must refresh the lenses'); - assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0); - }); + test('keeps ready for body edits following an ignored metadata example', () => { + const original = `${SCRIPT}\n"""\n# /// script\n# dependencies = ["example"]\n"""\nprint("body")`; + ready(original); + assert.strictEqual(lens(original.replace('"body"', '"changed"'), true).command?.command, ''); + }); - test('shows nothing for a routed script that was not just set up', () => { - assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri), {} as never).length, 0); - }); + test('keeps a lens when the closing marker is removed', () => { + ready(); + assert.strictEqual(lens(SCRIPT.replace('# ///\n', ''), true).command?.command, SETUP_COMMAND); + }); - test('stays hidden while the document is dirty', () => { - provider.noteEnvironmentReady(scriptUri, '3.12.4'); + test('removes the lens when the script block is removed', () => { + ready(); + assert.deepStrictEqual(provider.provideCodeLenses(document('print("hello")', true), {} as never), []); + }); - assert.strictEqual(provider.provideCodeLenses(makeDocument(scriptUri, true), {} as never).length, 0); - }); + test('anchors the lens to the live marker with BOM and CRLF', () => { + const text = `\uFEFF#!/usr/bin/env python\r\n${SCRIPT.replace(/\n/g, '\r\n')}`; + ready(text); + const result = lens(text); + assert.strictEqual(result.range.start.line, 1); + assert.strictEqual(result.range.start.character, 0); + assert.strictEqual(result.command?.command, ''); + }); + + test('offers retry during temporary unavailability and restores ready on recovery', () => { + ready(); + routing.setEnvironmentUnavailable(scriptUri, true); + assert.strictEqual(lens().command?.command, SETUP_COMMAND); + routing.setEnvironmentUnavailable(scriptUri, false); + assert.strictEqual(lens().command?.title, 'Script environment ready (Python 3.12.4)'); + }); - test('does not leak timers past disposal', () => { - provider.noteEnvironmentReady(scriptUri, '3.12.4'); + test('offers setup after an environment is invalidated', () => { + ready(); + routing.setValidatedAssociation(scriptUri, false); + assert.strictEqual(lens().command?.command, SETUP_COMMAND); + }); - provider.dispose(); + test('keeps two scripts and their interpreter versions independent', () => { + const other = Uri.file(path.join(process.cwd(), 'lens-tests', 'other.py')); + ready(); + ready(SCRIPT, '3.11.9', other); + assert.strictEqual(lens().command?.title, 'Script environment ready (Python 3.12.4)'); + assert.strictEqual(lens(SCRIPT, true, other).command?.title, 'Script environment ready (Python 3.11.9)'); + }); - assert.doesNotThrow(() => clock.tick(READY_CONFIRMATION_TIMEOUT_MS + 1)); - }); + test('refreshes when a validated interpreter version changes without a routeability change', () => { + ready(); + const changed = sinon.spy(); + provider.onDidChangeCodeLenses(changed); + routing.setValidatedAssociation(scriptUri, true, '3.13.1'); + sinon.assert.calledOnce(changed); + assert.strictEqual(lens().command?.title, 'Script environment ready (Python 3.13.1)'); + }); + + test('does not decorate resources unsupported by inline setup', () => { + for (const uri of [Uri.parse('untitled:app.py'), scriptUri.with({ path: `${scriptUri.path}i` })]) { + assert.deepStrictEqual(provider.provideCodeLenses(document(SCRIPT, false, uri), {} as never), []); + } + assert.deepStrictEqual( + provider.provideCodeLenses(document(`${'# padding\n'.repeat(MAX_HEADER_BYTES)}${SCRIPT}`), {} as never), + [], + ); + }); + + test('stops publishing after disposal', () => { + const changed = sinon.spy(); + provider.onDidChangeCodeLenses(changed); + provider.dispose(); + ready(); + sinon.assert.notCalled(changed); + assert.deepStrictEqual(provider.provideCodeLenses(document(), {} as never), []); }); }); diff --git a/src/test/features/inlineScript/lazyDetector.unit.test.ts b/src/test/features/inlineScript/lazyDetector.unit.test.ts index 37ae03447..fa524776e 100644 --- a/src/test/features/inlineScript/lazyDetector.unit.test.ts +++ b/src/test/features/inlineScript/lazyDetector.unit.test.ts @@ -470,7 +470,202 @@ suite('InlineScriptLazyDetector', () => { detector.dispose(); }); - test('invalidates routing for a CRLF dependency edit using source offsets', async () => { + test('preserves routing when a whole-document body edit leaves the block unchanged', async () => { + const uri = Uri.file(path.join(process.cwd(), 'body-edit.py')); + const original = '# /// script\n# dependencies = []\n# ///\n\nprint("old")'; + const changed = original.replace('"old"', '"new"'); + const metadata = ism.readInlineScriptMetadata(original)!; + readMetadataStub.resolves(metadata); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => changed }, + contentChanges: [{ range: undefined as never, rangeOffset: 0, rangeLength: original.length, text: changed }], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + assert.strictEqual(routingRegistry.getEnvironmentVersion(uri), '3.12.4'); + assert.deepStrictEqual(routingRegistry.getMetadata(uri), metadata); + detector.dispose(); + }); + + test('still invalidates a whole-document edit when the raw metadata changes', async () => { + const uri = Uri.file(path.join(process.cwd(), 'header-edit.py')); + const original = '# /// script\n# dependencies = []\n# ///\n'; + const changed = original.replace('dependencies = []', 'dependencies=[]'); + readMetadataStub.resolves(ism.readInlineScriptMetadata(original)); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => changed }, + contentChanges: [{ range: undefined as never, rangeOffset: 0, rangeLength: original.length, text: changed }], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('restores routeability when an unsaved metadata edit is undone to the saved fingerprint', async () => { + const uri = Uri.file(path.join(process.cwd(), 'header-undo.py')); + const original = '# /// script\n# dependencies = []\n# ///\n'; + const changed = original.replace('dependencies = []', 'dependencies = ["requests"]'); + const metadata = ism.readInlineScriptMetadata(original); + assert.ok(metadata?.sourceHash); + readMetadataStub.resolves(metadata); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + const changeDocument = (text: string) => { + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => text }, + contentChanges: [{ range: undefined as never, rangeOffset: 0, rangeLength: original.length, text }], + reason: undefined, + }); + }; + + changeDocument(changed); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); + assert.deepStrictEqual(routingRegistry.getMetadata(uri), metadata); + + changeDocument(original); + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + assert.strictEqual(routingRegistry.getEnvironmentVersion(uri), '3.12.4'); + detector.dispose(); + }); + + test('does not restore stale routing when a pending read completes after a live metadata edit', async () => { + const uri = Uri.file(path.join(process.cwd(), 'pending-header-read.py')); + const original = '# /// script\n# dependencies = ["requests"]\n# ///\n'; + const changed = original.replace('requests', 'httpx'); + const metadata = ism.readInlineScriptMetadata(original); + assert.ok(metadata?.sourceHash); + const staleRead = createDeferred(); + readMetadataStub.returns(staleRead.promise); + routingRegistry.setMetadata(uri, metadata); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + const detector = createDetector(); + + const opening = fireOpen(uri); + assert.strictEqual(readMetadataStub.callCount, 1); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => changed }, + contentChanges: [ + { range: undefined as never, rangeOffset: original.indexOf('requests'), rangeLength: 8, text: 'httpx' }, + ], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + + staleRead.resolve(metadata); + await opening; + + assert.deepStrictEqual(routingRegistry.getMetadata(uri), metadata); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('invalidates dependency edits after an unchanged block moves beyond its old offsets', async () => { + const uri = Uri.file(path.join(process.cwd(), 'moved-header.py')); + const original = '# /// script\n# dependencies = ["requests"]\n# ///\n'; + const metadata = ism.readInlineScriptMetadata(original)!; + readMetadataStub.resolves(metadata); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + const prefix = '# leading comment\n'.repeat(20); + const moved = prefix + original; + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => moved }, + contentChanges: [{ range: undefined as never, rangeOffset: 0, rangeLength: 0, text: prefix }], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + const dependencyOffset = moved.indexOf('requests'); + assert.ok(dependencyOffset > metadata.sourceRange!.end); + const changed = moved.replace('requests', 'httpx'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => changed }, + contentChanges: [{ range: undefined as never, rangeOffset: dependencyOffset, rangeLength: 8, text: 'httpx' }], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('invalidates changed dependencies between embedded TOML closing and opening markers', async () => { + const uri = Uri.file(path.join(process.cwd(), 'embedded-markers.py')); + const original = [ + '# /// script', + '# note = """', + '# ///', + '# """', + '# dependencies = ["requests"]', + '# other = """', + '# /// script', + '# """', + '# ///', + ].join('\n'); + const metadata = ism.readInlineScriptMetadata(original); + assert.ok(metadata?.sourceHash); + readMetadataStub.resolves(metadata); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => original.replace('requests', 'httpx') }, + contentChanges: [ + { range: undefined as never, rangeOffset: original.indexOf('requests'), rangeLength: 8, text: 'httpx' }, + ], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('invalidates when a preceding non-script opener changes structural block selection', async () => { + const uri = Uri.file(path.join(process.cwd(), 'preceding-marker.py')); + const original = '# note\n# /// script\n# dependencies = ["requests"]\n# ///'; + const changed = original.replace('# note', '# /// other'); + const metadata = ism.readInlineScriptMetadata(original); + assert.ok(metadata?.sourceHash); + assert.strictEqual(ism.readInlineScriptMetadata(changed), undefined); + readMetadataStub.resolves(metadata); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => changed }, + contentChanges: [{ range: undefined as never, rangeOffset: 0, rangeLength: 6, text: '# /// other' }], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('keeps routing for body edits after an ignored unfinished metadata example', async () => { + const uri = Uri.file(path.join(process.cwd(), 'body-example.py')); + const original = '# /// script\n# dependencies = []\n# ///\n\n"""\n# /// script\n# dependencies = ["example"]\n"""\nprint("body")'; + const metadata = ism.readInlineScriptMetadata(original); + assert.ok(metadata?.sourceHash); + readMetadataStub.resolves(metadata); + const detector = createDetector(); + await fireOpen(uri); + routingRegistry.setValidatedAssociation(uri, true, '3.12.4'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => original.replace('"body"', '"changed"') }, + contentChanges: [ + { range: undefined as never, rangeOffset: original.indexOf('"body"'), rangeLength: 6, text: '"changed"' }, + ], + reason: undefined, + }); + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + detector.dispose(); + }); + + test('invalidates routing for a CRLF dependency edit beyond normalized offsets', async () => { const uri = Uri.file(path.resolve('/elsewhere/crlf.py')); const source = [ '# /// script', @@ -493,9 +688,21 @@ suite('InlineScriptLazyDetector', () => { await fireOpen(uri); routingRegistry.setValidatedAssociation(uri, true); - fireChange(uri, makeContentChanges(dependencyOffset)); + const changed = source.replace('requests', 'httpx'); + changeListener!({ + document: { ...makeDoc(uri), isDirty: true, getText: () => changed }, + contentChanges: [ + { + range: undefined as never, + rangeOffset: source.indexOf('requests'), + rangeLength: 'requests'.length, + text: 'httpx', + }, + ], + reason: undefined, + }); - assert.strictEqual(routingRegistry.getMetadata(uri), undefined); + assert.deepStrictEqual(routingRegistry.getMetadata(uri), metadata); assert.strictEqual(routingRegistry.shouldRoute(uri), false); detector.dispose(); }); diff --git a/src/test/features/inlineScript/setupEnvironment.unit.test.ts b/src/test/features/inlineScript/setupEnvironment.unit.test.ts index fd4908645..17ee83f22 100644 --- a/src/test/features/inlineScript/setupEnvironment.unit.test.ts +++ b/src/test/features/inlineScript/setupEnvironment.unit.test.ts @@ -4,13 +4,17 @@ import assert from 'assert'; import * as sinon from 'sinon'; import * as typemoq from 'typemoq'; -import { Uri } from 'vscode'; +import { Disposable, Uri } from 'vscode'; import { PythonEnvironment } from '../../../api'; import { INLINE_SCRIPT_MANAGER_ID } from '../../../common/constants'; import { InlineScriptMetadata } from '../../../common/inlineScript/metadata'; import * as metadataApi from '../../../common/inlineScript/metadata'; -import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; +import { + getInlineScriptMetadataRoutingIdentity, + InlineScriptRoutingRegistry, +} from '../../../common/inlineScript/routingRegistry'; import { InlineScriptStrings } from '../../../common/localize'; +import { createDeferred } from '../../../common/utils/deferred'; import * as winapi from '../../../common/window.apis'; import * as wapi from '../../../common/workspace.apis'; import { @@ -20,8 +24,10 @@ import { setupInlineScriptEnvironmentHandler, } from '../../../features/inlineScript/setupEnvironment'; import * as extensionVersionCheck from '../../../features/inlineScript/extensionVersionCheck'; +import { InlineScriptCodeLensProvider } from '../../../features/inlineScript/codeLens'; import type { EnvironmentManagers } from '../../../features/envManagers'; import { InternalEnvironmentManager } from '../../../managers/common/registeredManagers'; +import { MockDocument } from '../../mocks/mockDocument'; function makeEnv(): PythonEnvironment { return { @@ -211,6 +217,19 @@ suite('setUpInlineScriptEnvironmentsInWorkspace', () => { manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); }); + test('searches for Python file extensions case-insensitively', async () => { + findFilesStub.resolves([]); + + await setUpInlineScriptEnvironmentsInWorkspace(em.object, routing); + + sinon.assert.calledOnceWithExactly( + findFilesStub, + '**/*.{py,Py,pY,PY}', + '{**/.venv/**,**/node_modules/**}', + 1000, + ); + }); + test('only sets up the selected files that declare inline metadata', async () => { findFilesStub.resolves([withMeta, withoutMeta]); readMetadataStub.callsFake(async (uri: Uri) => @@ -328,7 +347,9 @@ suite('setupInlineScriptEnvironmentHandler', () => { let errorStub: sinon.SinonStub; let saveStub: sinon.SinonStub; let promptStub: sinon.SinonStub; - let readySpy: sinon.SinonStub; + let warningStub: sinon.SinonStub; + let readHeaderStub: sinon.SinonStub; + let currentText: string; setup(() => { em = typemoq.Mock.ofType(); @@ -339,8 +360,9 @@ suite('setupInlineScriptEnvironmentHandler', () => { openDocumentsStub = sinon.stub(wapi, 'getOpenTextDocuments').returns([]); errorStub = sinon.stub(winapi, 'showErrorMessage').resolves(undefined); sinon.stub(winapi, 'showInformationMessage').resolves(undefined); - sinon.stub(winapi, 'showWarningMessage').resolves(undefined); - readySpy = sinon.stub(); + warningStub = sinon.stub(winapi, 'showWarningMessage').resolves(undefined); + currentText = '# /// script\n# dependencies = ["requests"]\n# ///\nprint("hello")'; + readHeaderStub = sinon.stub(metadataApi, 'readInlineScriptHeaderFromFile').callsFake(async () => currentText); promptStub = sinon.stub(extensionVersionCheck, 'promptUpdateExtensionsForInlineScripts').resolves(); saveStub = sinon.stub().resolves(true); }); @@ -350,8 +372,22 @@ suite('setupInlineScriptEnvironmentHandler', () => { sinon.restore(); }); - function openDirtyDocument(isDirty = true): void { - openDocumentsStub.returns([{ uri: scriptUri, isDirty, save: saveStub }]); + function openDirtyDocument(isDirty = true) { + const document = { + uri: scriptUri, + isDirty, + version: 1, + getText: () => currentText, + save: async (): Promise => { + const saved = await saveStub(); + if (saved) { + document.isDirty = false; + } + return saved; + }, + }; + openDocumentsStub.returns([document]); + return document; } function expectEnvironmentCreated(): PythonEnvironment { @@ -427,27 +463,133 @@ suite('setupInlineScriptEnvironmentHandler', () => { sinon.assert.notCalled(errorStub); }); - test('reports the ready environment and its Python version once setup succeeds', async () => { - expectEnvironmentCreated(); + for (const malformed of [ + '# /// script', + '# /// script\n# dependencies = [\n# ///', + '# /// script\n# dependencies = "requests"\n# ///', + '# /// script\n# dependencies = [""]\n# ///', + '# /// script\n# ///\n# /// script\n# ///', + '# /// script\n# ///\n\n# /// script\n#bad', + ]) { + test(`warns on explicit setup without saving or creating malformed metadata: ${JSON.stringify(malformed)}`, async () => { + currentText = malformed; + openDirtyDocument(); + await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); + sinon.assert.calledOnce(warningStub); + sinon.assert.notCalled(errorStub); + sinon.assert.notCalled(saveStub); + manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); + }); + } - await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri); + test('warns about an invalid Python requirement before installing anything', async () => { + currentText = '# /// script\n# requires-python = "not a version specifier"\n# ///'; + await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); + sinon.assert.calledOnceWithExactly(warningStub, InlineScriptStrings.invalidPythonRequirement); + manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); + }); - sinon.assert.calledOnceWithExactly(readySpy, scriptUri, '3.12.0'); + test('rechecks metadata after saving instead of creating from a changed invalid block', async () => { + openDirtyDocument(); + saveStub.callsFake(async () => { + currentText = '# /// script\n# dependencies = [\n# ///'; + return true; + }); + await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); + sinon.assert.calledOnce(saveStub); + sinon.assert.calledOnce(warningStub); + manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); }); - test('reports no ready environment when setup produced none', async () => { - manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(undefined)); + test('does not restore routing when an older post-save read finishes after a newer header edit', async () => { + const metadata = metadataApi.readInlineScriptMetadata(currentText)!; + routing.setMetadata(scriptUri, metadata); + routing.setValidatedAssociation(scriptUri, true, '3.12.0'); + const subscription = routing.onDidChangeMetadata((event) => { + if (event.metadata) { + routing.setValidatedAssociation(scriptUri, true, '3.12.0'); + } + }); + const document = openDirtyDocument(); + const readStarted = createDeferred(); + const read = createDeferred(); + readMetadataStub.callsFake(() => { + readStarted.resolve(); + return read.promise; + }); + const pending = setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); + await readStarted.promise; + currentText = '# /// script\n# dependencies = [\n# ///'; + document.version += 1; + document.isDirty = true; + routing.clearMetadata(scriptUri); + routing.setValidatedAssociation(scriptUri, false); + read.resolve(metadata); + await pending; + assert.strictEqual(routing.shouldRoute(scriptUri), false); + assert.strictEqual(routing.getMetadata(scriptUri), undefined); + sinon.assert.calledOnce(warningStub); + manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); + subscription.dispose(); + }); - await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri); + test('explicit setup restores metadata, routing, and ready after a clean Undo or Revert', async () => { + const metadata = metadataApi.readInlineScriptMetadata(currentText)!; + routing.setMetadata(scriptUri, metadata); + routing.setValidatedAssociation(scriptUri, true, '3.12.0'); + routing.clearMetadata(scriptUri); + routing.setValidatedAssociation(scriptUri, false); + openDirtyDocument(false); + readMetadataStub.resolves(metadata); + const environment = makeEnv(); + manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.resolve(environment)); + em.setup((m) => m.setEnvironment(scriptUri, environment)).returns(async () => { + routing.setValidatedAssociation( + scriptUri, + routing.getMetadataIdentity(scriptUri) === getInlineScriptMetadataRoutingIdentity(metadata), + environment.version, + ); + }); + + await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); - sinon.assert.notCalled(readySpy); + sinon.assert.notCalled(saveStub); + assert.deepStrictEqual(routing.getMetadata(scriptUri), metadata); + assert.strictEqual(routing.shouldRoute(scriptUri), true); + sinon.stub(wapi, 'onDidChangeTextDocument').returns(new Disposable(() => undefined)); + const provider = new InlineScriptCodeLensProvider(routing, 'setup'); + try { + const document = new MockDocument(currentText, scriptUri.fsPath, async () => true); + const lenses = provider.provideCodeLenses(document, {} as never); + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].command?.command, ''); + assert.strictEqual(lenses[0].command?.title, 'Script environment ready (Python 3.12.0)'); + } finally { + provider.dispose(); + } }); - test('reports no ready environment when setup throws', async () => { - manager.setup((m) => m.create(scriptUri, undefined)).returns(() => Promise.reject(new Error('boom'))); + test('reports save exceptions instead of letting the command reject', async () => { + openDirtyDocument(); + saveStub.rejects(new Error('Cannot save')); + await assert.doesNotReject(setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri)); + sinon.assert.calledOnce(errorStub); + manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); + }); - await setupInlineScriptEnvironmentHandler(em.object, routing, readySpy)(scriptUri); + test('reports an unreadable closed script without trying setup', async () => { + readHeaderStub.resolves(undefined); + await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); + sinon.assert.calledOnceWithExactly(errorStub, InlineScriptStrings.scriptReadFailedBeforeSetup); + sinon.assert.notCalled(warningStub); + manager.verify((m) => m.create(typemoq.It.isAny(), typemoq.It.isAny()), typemoq.Times.never()); + }); - sinon.assert.notCalled(readySpy); + test('accepts an empty block and creates its environment', async () => { + currentText = '# /// script\n# ///'; + expectEnvironmentCreated(); + await setupInlineScriptEnvironmentHandler(em.object, routing)(scriptUri); + manager.verify((m) => m.create(scriptUri, undefined), typemoq.Times.once()); + sinon.assert.notCalled(warningStub); }); }); diff --git a/src/test/features/interpreterSelection.unit.test.ts b/src/test/features/interpreterSelection.unit.test.ts index 2f0e077ee..0fd7f6940 100644 --- a/src/test/features/interpreterSelection.unit.test.ts +++ b/src/test/features/interpreterSelection.unit.test.ts @@ -2,10 +2,13 @@ // Licensed under the MIT License. import * as assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import { ConfigurationChangeEvent, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject, SetEnvironmentScope } from '../../api'; +import { normalizePath } from '../../common/utils/pathUtils'; import * as windowApis from '../../common/window.apis'; import * as workspaceApis from '../../common/workspace.apis'; import { @@ -396,6 +399,148 @@ suite('Interpreter Selection - Priority Chain', () => { assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(absoluteInterpreterPath)); }); + suite('bare command names', () => { + let commandDir: string; + let originalPath: string | undefined; + + /** Creates an executable named `name` in a temporary directory placed first on PATH. */ + async function putCommandOnPath(name: string): Promise { + commandDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dip-command-')); + const executable = path.join(commandDir, process.platform === 'win32' ? `${name}.exe` : name); + await fs.writeFile(executable, ''); + await fs.chmod(executable, 0o755); + originalPath = process.env.PATH; + process.env.PATH = `${commandDir}${path.delimiter}${originalPath ?? ''}`; + return executable; + } + + function useDefaultInterpreterPath(value: string): void { + sandbox + .stub(helpers, 'getUserConfiguredSetting') + .callsFake((section: string, key: string) => + section === 'python' && key === 'defaultInterpreterPath' ? value : undefined, + ); + } + + teardown(async () => { + if (originalPath !== undefined) { + process.env.PATH = originalPath; + originalPath = undefined; + } + if (commandDir) { + await fs.remove(commandDir); + } + }); + + for (const testCase of [ + { + scope: 'workspace folder', + resolve: async () => { + const workspaceUri = Uri.file(path.resolve('test', 'workspace')); + const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder; + sandbox + .stub(workspaceApis, 'getConfiguration') + .returns(createMockConfig([]) as WorkspaceConfiguration); + sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder); + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + return resolveEnvironmentByPriority( + workspaceUri, + mockEnvManagers as unknown as EnvironmentManagers, + mockProjectManager as unknown as PythonProjectManager, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ); + }, + }, + { + scope: 'global scope', + resolve: async () => { + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]); + return resolveGlobalEnvironmentByPriority( + mockEnvManagers as unknown as EnvironmentManagers, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ); + }, + }, + ]) { + test(`should resolve a command name from PATH for the ${testCase.scope}`, async () => { + const executable = await putCommandOnPath('pyenvs-test-python3'); + useDefaultInterpreterPath('pyenvs-test-python3'); + mockNativeFinder.resolve.resolves({ executable, version: '3.12.4', prefix: commandDir }); + mockApi.resolveEnvironment.resolves(mockSystemEnv); + + const result = await testCase.resolve(); + + assert.strictEqual(result.source, 'defaultInterpreterPath'); + assert.strictEqual( + normalizePath(mockNativeFinder.resolve.firstCall.args[0]), + normalizePath(executable), + ); + }); + } + + test('should resolve a command name that is not on PATH against the workspace folder', async () => { + const workspaceUri = Uri.file(path.resolve('test', 'workspace')); + const workspaceFolder = { name: 'workspace', uri: workspaceUri } as WorkspaceFolder; + const expected = path.resolve(workspaceUri.fsPath, 'pyenvs-test-missing-python'); + sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration); + sandbox.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder); + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + useDefaultInterpreterPath('pyenvs-test-missing-python'); + mockNativeFinder.resolve.rejects(new Error('not found')); + mockVenvManager.get.resolves(undefined); + + await resolveEnvironmentByPriority( + workspaceUri, + mockEnvManagers as unknown as EnvironmentManagers, + mockProjectManager as unknown as PythonProjectManager, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ); + + assert.ok(mockNativeFinder.resolve.calledOnceWithExactly(expected)); + }); + + test('should treat the default value "python" as not configured', async () => { + await putCommandOnPath('python'); + sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration); + useDefaultInterpreterPath('python'); + mockVenvManager.get.resolves(mockVenvEnv); + + const result = await resolveEnvironmentByPriority( + testUri, + mockEnvManagers as unknown as EnvironmentManagers, + mockProjectManager as unknown as PythonProjectManager, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ); + + assert.strictEqual(result.source, 'autoDiscovery'); + assert.strictEqual(result.environment, mockVenvEnv); + assert.ok(mockNativeFinder.resolve.notCalled); + }); + + test('should not pick a batch file from PATH on Windows', async function () { + if (process.platform !== 'win32') { + this.skip(); + } + const shim = await putCommandOnPath('pyenvs-test-shim'); + await fs.move(shim, path.join(commandDir, 'pyenvs-test-shim.bat')); + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]); + useDefaultInterpreterPath('pyenvs-test-shim'); + mockNativeFinder.resolve.rejects(new Error('not found')); + + await resolveGlobalEnvironmentByPriority( + mockEnvManagers as unknown as EnvironmentManagers, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ); + + assert.ok(mockNativeFinder.resolve.calledOnceWithExactly('pyenvs-test-shim')); + }); + }); + test('should pass an absolute defaultInterpreterPath to the native finder unchanged', async () => { // Absolute paths must not be re-resolved against the workspace folder. const workspaceUri = Uri.file(path.resolve('/test/workspace')); @@ -1595,6 +1740,95 @@ suite('Interpreter Selection - registerInterpreterSettingsChangeListener', () => disposable.dispose(); }); + test('should apply corrected defaultInterpreterPath while the initial warning remains unresolved', async () => { + let configChangeCallback: ((e: ConfigurationChangeEvent) => void) | undefined; + sandbox.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((callback) => { + configChangeCallback = callback; + return { dispose: () => {} }; + }); + + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([{ uri: testUri, name: 'test', index: 0 }]); + sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration); + + const missingInterpreter = path.join(testUri.fsPath, 'missing', 'python'); + const correctedInterpreter = path.join(testUri.fsPath, 'environment-b', 'python'); + let configuredInterpreter = missingInterpreter; + sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake( + (section: string, key: string, scope?: Uri) => { + if ( + scope?.fsPath === testUri.fsPath && + section === 'python' && + key === 'defaultInterpreterPath' + ) { + return configuredInterpreter; + } + return undefined; + }, + ); + + mockNativeFinder.resolve.callsFake(async (interpreterPath: string) => { + if (interpreterPath === correctedInterpreter) { + return { + executable: correctedInterpreter, + version: '3.12.0', + prefix: path.dirname(correctedInterpreter), + }; + } + throw new Error('Interpreter not found'); + }); + mockApi.resolveEnvironment.resolves({ + ...mockVenvEnv, + envId: { id: 'environment-b', managerId: 'ms-python.python:system' }, + execInfo: { run: { executable: correctedInterpreter } }, + }); + + const warningPending = new Promise(() => {}); + const showWarnStub = sandbox.stub(windowApis, 'showWarningMessage').returns(warningPending); + const clock = sandbox.useFakeTimers(); + + const setupCompletion = Promise.race([ + applyInitialEnvironmentSelection( + mockEnvManagers as unknown as EnvironmentManagers, + mockProjectManager as unknown as PythonProjectManager, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 1_000)), + ]); + await clock.tickAsync(1_000); + const setupCompleted = await setupCompletion; + + assert.strictEqual(setupCompleted, true, 'Initial selection should not wait for the warning response'); + assert.ok(showWarnStub.calledOnce, 'The unresolved interpreter warning should still be shown'); + + const disposable = registerInterpreterSettingsChangeListener( + mockEnvManagers as unknown as EnvironmentManagers, + mockProjectManager as unknown as PythonProjectManager, + mockNativeFinder as unknown as NativePythonFinder, + mockApi as unknown as PythonEnvironmentApi, + ); + assert.ok(configChangeCallback, 'Config change callback should be registered after initial selection'); + + configuredInterpreter = correctedInterpreter; + mockEnvManagers.setEnvironment.resetHistory(); + + await configChangeCallback({ + affectsConfiguration: (section: string) => section === 'python.defaultInterpreterPath', + }); + + assert.ok(mockEnvManagers.setEnvironment.calledOnce, 'The corrected interpreter should be selected'); + const selectedScope = mockEnvManagers.setEnvironment.firstCall.args[0]; + assert.ok(selectedScope && !Array.isArray(selectedScope), 'The workspace folder should be selected'); + assert.strictEqual(selectedScope.fsPath, testUri.fsPath); + assert.strictEqual( + mockEnvManagers.setEnvironment.firstCall.args[1]?.execInfo?.run.executable, + correctedInterpreter, + ); + assert.ok(showWarnStub.calledOnce, 'The pending warning should not be duplicated'); + + disposable.dispose(); + }); + test('should re-run priority chain when python-envs.defaultEnvManager changes', async () => { let configChangeCallback: ((e: ConfigurationChangeEvent) => void) | undefined; sandbox.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((callback) => { diff --git a/src/test/features/settings/settingHelpers.activation.unit.test.ts b/src/test/features/settings/settingHelpers.activation.unit.test.ts new file mode 100644 index 000000000..8060fc794 --- /dev/null +++ b/src/test/features/settings/settingHelpers.activation.unit.test.ts @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { isEnvironmentsExtensionEnabled } from '../../../features/settings/settingHelpers'; + +type SettingInspection = { + defaultValue?: boolean; + globalValue?: boolean; + workspaceValue?: boolean; + workspaceFolderValue?: boolean; +}; + +function createConfiguration(inspection: SettingInspection | undefined): WorkspaceConfiguration { + return { + inspect: sinon.stub().withArgs('useEnvironmentsExtension').returns(inspection), + } as unknown as WorkspaceConfiguration; +} + +function createWorkspaceFolder(name: string, index: number): WorkspaceFolder { + return { + uri: Uri.joinPath(Uri.file(process.cwd()), name), + name, + index, + }; +} + +suite('isEnvironmentsExtensionEnabled', () => { + teardown(() => { + sinon.restore(); + }); + + test('ignores a contributed default when no value is explicitly configured', () => { + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python', undefined) + .returns(createConfiguration({ defaultValue: false })); + + assert.strictEqual(isEnvironmentsExtensionEnabled(), true); + }); + + test('uses an explicit user false value when no workspace is open', () => { + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns(undefined); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python', undefined) + .returns(createConfiguration({ globalValue: false })); + + assert.strictEqual(isEnvironmentsExtensionEnabled(), false); + }); + + test('uses a workspace true value over a user false value', () => { + const workspaceFolder = createWorkspaceFolder('workspace', 0); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python', workspaceFolder.uri) + .returns(createConfiguration({ globalValue: false, workspaceValue: true })); + + assert.strictEqual(isEnvironmentsExtensionEnabled(), true); + }); + + test('uses a workspace false value over a user true value', () => { + const workspaceFolder = createWorkspaceFolder('workspace', 0); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python', workspaceFolder.uri) + .returns(createConfiguration({ globalValue: true, workspaceValue: false })); + + assert.strictEqual(isEnvironmentsExtensionEnabled(), false); + }); + + test('uses a workspace-folder false value over a workspace true value', () => { + const workspaceFolder = createWorkspaceFolder('workspace', 0); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon + .stub(workspaceApis, 'getConfiguration') + .withArgs('python', workspaceFolder.uri) + .returns(createConfiguration({ workspaceValue: true, workspaceFolderValue: false })); + + assert.strictEqual(isEnvironmentsExtensionEnabled(), false); + }); + + test('activates when any workspace folder resolves to true', () => { + const disabledFolder = createWorkspaceFolder('disabled', 0); + const enabledFolder = createWorkspaceFolder('enabled', 1); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([disabledFolder, enabledFolder]); + const getConfiguration = sinon.stub(workspaceApis, 'getConfiguration'); + getConfiguration + .withArgs('python', disabledFolder.uri) + .returns(createConfiguration({ workspaceValue: false })); + getConfiguration + .withArgs('python', enabledFolder.uri) + .returns(createConfiguration({ workspaceValue: false, workspaceFolderValue: true })); + + assert.strictEqual(isEnvironmentsExtensionEnabled(), true); + }); +}); diff --git a/src/test/features/settings/settingHelpers.unit.test.ts b/src/test/features/settings/settingHelpers.unit.test.ts index d94819b9e..dc0897efa 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -435,6 +435,36 @@ suite('Setting Helpers - Empty Path Bug Fix', () => { }); suite('setAllManagerSettings - Multi-root Workspace', () => { + for (const persistNewProjects of [false, true]) { + test(`preserves ${ + persistNewProjects ? 'original tool path casing' : 'public path normalization' + }`, async () => { + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon + .stub(workspaceApis, 'getWorkspaceFile') + .returns(Uri.file(path.join(workspacePath, 'multi.code-workspace'))); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder); + const config = createMockConfigForWorkspace(); + sinon.stub(config, 'inspect').returns(undefined); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + const project = new PythonProjectsImpl('Mixed Service', Uri.joinPath(workspaceUri, 'Mixed Service')); + await setAllManagerSettings( + [{ project, envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }], + persistNewProjects, + ); + const update = updateCalls.find((call) => call.key === 'pythonProjects'); + assert.ok(update); + assert.deepStrictEqual(update.value, [ + { + path: process.platform === 'win32' && !persistNewProjects ? 'mixed service' : 'Mixed Service', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + }, + ]); + assert.strictEqual(update.target, ConfigurationTarget.WorkspaceFolder); + }); + } + test('should use "." for workspace root path instead of empty string when workspaceFile exists', async () => { // Setup: multi-root workspace with workspace file sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); diff --git a/src/test/features/terminal/shellStartupActivationVariablesManager.unit.test.ts b/src/test/features/terminal/shellStartupActivationVariablesManager.unit.test.ts index 9ff6b1cd0..d5bfe9d3a 100644 --- a/src/test/features/terminal/shellStartupActivationVariablesManager.unit.test.ts +++ b/src/test/features/terminal/shellStartupActivationVariablesManager.unit.test.ts @@ -1,8 +1,9 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { Disposable, GlobalEnvironmentVariableCollection, Uri, WorkspaceFolder } from 'vscode'; +import { ConfigurationChangeEvent, Disposable, GlobalEnvironmentVariableCollection, Uri, WorkspaceFolder } from 'vscode'; import { DidChangeEnvironmentEventArgs, PythonEnvironment, PythonProjectEnvironmentApi } from '../../../api'; +import { createDeferred } from '../../../common/utils/deferred'; import * as workspaceApis from '../../../common/workspace.apis'; import { ShellStartupActivationVariablesManagerImpl } from '../../../features/terminal/shellStartupActivationVariablesManager'; import { ShellEnvsProvider } from '../../../features/terminal/shells/startupProvider'; @@ -24,14 +25,18 @@ function makeEnvironment(id: string, managerId: string): PythonEnvironment { class RecordingEnvsProvider implements ShellEnvsProvider { public readonly shellType = 'pwsh'; public readonly updated: PythonEnvironment[] = []; + public readonly updatedCollections: unknown[] = []; + public readonly removedCollections: unknown[] = []; public removeCalls = 0; - updateEnvVariables(_collection: unknown, env: PythonEnvironment): void { + updateEnvVariables(collection: unknown, env: PythonEnvironment): void { this.updated.push(env); + this.updatedCollections.push(collection); } - removeEnvVariables(): void { + removeEnvVariables(collection: unknown): void { this.removeCalls += 1; + this.removedCollections.push(collection); } getEnvVariables(): Map | undefined { @@ -49,6 +54,7 @@ suite('ShellStartupActivationVariablesManager', () => { let scopedCollection: object; let envCollection: GlobalEnvironmentVariableCollection; let getEnvironmentStub: sinon.SinonStub; + let configurationListener: ((e: ConfigurationChangeEvent) => void | Promise) | undefined; let changeListener: ((e: DidChangeEnvironmentEventArgs) => Promise) | undefined; let manager: ShellStartupActivationVariablesManagerImpl; @@ -62,7 +68,11 @@ suite('ShellStartupActivationVariablesManager', () => { sinon.stub(terminalUtils, 'getAutoActivationType').returns(terminalUtils.ACT_TYPE_SHELL); sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(workspaceFolder); - sinon.stub(workspaceApis, 'onDidChangeConfiguration').returns(new Disposable(() => undefined)); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([]); + sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener) => { + configurationListener = listener; + return new Disposable(() => undefined); + }); getEnvironmentStub = sinon.stub().resolves(folderEnvironment); const api = { @@ -126,6 +136,88 @@ suite('ShellStartupActivationVariablesManager', () => { assert.strictEqual(provider.removeCalls, 1); }); + test('refreshes global startup variables after a global selection changes', async () => { + const newEnvironment = makeEnvironment('global-venv-b', 'ms-python.python:venv'); + await changeListener!({ uri: undefined, new: folderEnvironment, old: undefined }); + await changeListener!({ uri: undefined, new: newEnvironment, old: folderEnvironment }); + + sinon.assert.notCalled(getEnvironmentStub); + assert.deepStrictEqual(provider.updated, [folderEnvironment, newEnvironment]); + assert.deepStrictEqual(provider.updatedCollections, [envCollection, envCollection]); + }); + + test('uses the global change payload while the API still returns the previous selection', async () => { + const newEnvironment = makeEnvironment('global-venv-b', 'ms-python.python:venv'); + getEnvironmentStub.resolves(folderEnvironment); + + await changeListener!({ uri: undefined, new: newEnvironment, old: folderEnvironment }); + + sinon.assert.notCalled(getEnvironmentStub); + assert.deepStrictEqual(provider.updated, [newEnvironment]); + }); + + test('does not let pending global initialization overwrite a newer selection', async () => { + const newEnvironment = makeEnvironment('global-venv-b', 'ms-python.python:venv'); + const pendingInitialization = createDeferred(); + getEnvironmentStub.returns(pendingInitialization.promise); + + const initialization = manager.initialize(); + await changeListener!({ uri: undefined, new: newEnvironment, old: folderEnvironment }); + pendingInitialization.resolve(folderEnvironment); + await initialization; + + assert.deepStrictEqual(provider.updated, [newEnvironment]); + assert.deepStrictEqual(provider.updatedCollections, [envCollection]); + }); + + test('does not restore global startup variables after shell startup activation is disabled', async () => { + const pendingRefresh = createDeferred(); + getEnvironmentStub.returns(pendingRefresh.promise); + const initialization = manager.initialize(); + (terminalUtils.getAutoActivationType as sinon.SinonStub).returns('command'); + + await configurationListener!({ + affectsConfiguration: (section) => section === 'python-envs.terminal.autoActivationType', + } as ConfigurationChangeEvent); + pendingRefresh.resolve(folderEnvironment); + await initialization; + + assert.strictEqual(provider.updated.length, 0); + assert.deepStrictEqual(provider.removedCollections, [envCollection]); + }); + + test('removes stale global startup variables when no environment remains selected', async () => { + getEnvironmentStub.resolves(undefined); + + await changeListener!({ uri: undefined, new: undefined, old: folderEnvironment }); + + sinon.assert.notCalled(getEnvironmentStub); + assert.deepStrictEqual(provider.removedCollections, [envCollection]); + assert.strictEqual(provider.updated.length, 0); + }); + + test('does not overwrite folder startup variables on a global selection change', async () => { + (workspaceApis.getWorkspaceFolders as sinon.SinonStub).returns([workspaceFolder]); + + await changeListener!({ uri: undefined, new: scriptEnvironment, old: undefined }); + + sinon.assert.notCalled(getEnvironmentStub); + assert.strictEqual(provider.updated.length, 0); + assert.strictEqual(provider.removeCalls, 0); + }); + + for (const mode of ['command', 'off']) { + test(`ignores global environment changes when activation mode is ${mode}`, async () => { + (terminalUtils.getAutoActivationType as sinon.SinonStub).returns(mode); + + await changeListener!({ uri: undefined, new: scriptEnvironment, old: undefined }); + + sinon.assert.notCalled(getEnvironmentStub); + assert.strictEqual(provider.updated.length, 0); + assert.strictEqual(provider.removeCalls, 0); + }); + } + test('ignores environment changes when shell startup activation is off', async () => { (terminalUtils.getAutoActivationType as sinon.SinonStub).returns('command'); diff --git a/src/test/features/terminalEnvVarInjector.unit.test.ts b/src/test/features/terminalEnvVarInjector.unit.test.ts index 17b693ee3..1884308e4 100644 --- a/src/test/features/terminalEnvVarInjector.unit.test.ts +++ b/src/test/features/terminalEnvVarInjector.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import * as assert from 'assert'; +import fse from 'fs-extra'; import * as path from 'path'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; @@ -18,6 +19,7 @@ import { import { ActivationStrings, Common } from '../../common/localize'; import * as logging from '../../common/logging'; import * as persistentState from '../../common/persistentState'; +import { createDeferred } from '../../common/utils/deferred'; import * as windowApis from '../../common/window.apis'; import * as workspaceApis from '../../common/workspace.apis'; import { EnvVarManager } from '../../features/execution/envVariableManager'; @@ -151,6 +153,131 @@ suite('TerminalEnvVarInjector', () => { assert.strictEqual(mockScopedCollection.replace.called, false); }); + suite('env file changes', () => { + let envChangeCallback: ((args: { uri?: Uri; changeType: number }) => Promise) | undefined; + let variables: Record; + let variableReads: Promise>[]; + let existingFiles: Set; + const defaultEnvFile = path.join(testWorkspaceFolder.uri.fsPath, '.env'); + const configuredEnvFile = path.join(testWorkspaceFolder.uri.fsPath, 'configured.env'); + + setup(() => { + variables = {}; + variableReads = []; + existingFiles = new Set(); + workspaceFoldersValue = undefined; + sinon + .stub(fse, 'pathExists') + .callsFake(async (filePath) => existingFiles.has(path.resolve(filePath.toString()))); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(testWorkspaceFolder); + envVarManager.reset(); + envVarManager.setup((m) => m.onDidChangeEnvironmentVariables).returns( + () => (listener) => { + envChangeCallback = listener; + return new Disposable(() => {}); + }, + ); + envVarManager + .setup((m) => m.getEnvironmentVariables(typeMoq.It.isAny())) + .returns(() => variableReads.shift() ?? Promise.resolve({ ...variables })); + }); + + async function fireChange(changeType: number, filePath = defaultEnvFile): Promise { + assert.ok(envChangeCallback); + await envChangeCallback({ uri: Uri.file(filePath), changeType }); + } + + test('creating an env file with injection disabled preserves shell activation variables', async () => { + getConfigurationStub.returns(createMockConfig({ useEnvFile: false }) as WorkspaceConfiguration); + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await fireChange(2); + + sinon.assert.notCalled(mockScopedCollection.clear); + sinon.assert.notCalled(mockScopedCollection.delete); + sinon.assert.notCalled(mockScopedCollection.replace); + }); + + test('creating, editing, and deleting an env file updates only its injected variables', async () => { + getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration); + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + + existingFiles.add(defaultEnvFile); + variables = { TERMINAL_PROBE_VALUE: 'created' }; + await fireChange(2); + sinon.assert.calledWith(mockScopedCollection.replace, 'TERMINAL_PROBE_VALUE', 'created'); + + variables = { OTHER_VALUE: 'edited' }; + await fireChange(1); + sinon.assert.calledWith(mockScopedCollection.delete, 'TERMINAL_PROBE_VALUE'); + sinon.assert.calledWith(mockScopedCollection.replace, 'OTHER_VALUE', 'edited'); + + existingFiles.delete(defaultEnvFile); + variables = {}; + await fireChange(3); + sinon.assert.calledWith(mockScopedCollection.delete, 'OTHER_VALUE'); + sinon.assert.notCalled(mockScopedCollection.clear); + }); + + test('deleting one env file retains variables from the other configured file', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: true, envFilePath: configuredEnvFile }) as WorkspaceConfiguration, + ); + existingFiles.add(defaultEnvFile); + existingFiles.add(configuredEnvFile); + variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' }; + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await fireChange(2, configuredEnvFile); + + existingFiles.delete(configuredEnvFile); + variables = { PROJECT_VALUE: 'project' }; + await fireChange(3, configuredEnvFile); + + sinon.assert.calledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE'); + sinon.assert.neverCalledWith(mockScopedCollection.delete, 'PROJECT_VALUE'); + sinon.assert.notCalled(mockScopedCollection.clear); + }); + + test('deleting the project env file retains variables from the configured file', async () => { + getConfigurationStub.returns( + createMockConfig({ useEnvFile: true, envFilePath: configuredEnvFile }) as WorkspaceConfiguration, + ); + existingFiles.add(defaultEnvFile); + existingFiles.add(configuredEnvFile); + variables = { CONFIGURED_VALUE: 'configured', PROJECT_VALUE: 'project' }; + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + await fireChange(2); + + existingFiles.delete(defaultEnvFile); + variables = { CONFIGURED_VALUE: 'configured' }; + await fireChange(3); + + sinon.assert.calledWith(mockScopedCollection.delete, 'PROJECT_VALUE'); + sinon.assert.neverCalledWith(mockScopedCollection.delete, 'CONFIGURED_VALUE'); + sinon.assert.notCalled(mockScopedCollection.clear); + }); + + test('does not let an older env file refresh overwrite newer variables', async () => { + getConfigurationStub.returns(createMockConfig({ useEnvFile: true }) as WorkspaceConfiguration); + existingFiles.add(defaultEnvFile); + const olderRefresh = createDeferred>(); + const newerRefresh = createDeferred>(); + variableReads.push(olderRefresh.promise, newerRefresh.promise); + injector = new TerminalEnvVarInjector(envVarCollection.object, envVarManager.object); + + assert.ok(envChangeCallback); + const firstChange = envChangeCallback({ uri: Uri.file(defaultEnvFile), changeType: 3 }); + const secondChange = envChangeCallback({ uri: Uri.file(defaultEnvFile), changeType: 2 }); + + newerRefresh.resolve({ NEW_VALUE: 'new' }); + await secondChange; + olderRefresh.resolve({ OLD_VALUE: 'old' }); + await firstChange; + + sinon.assert.calledOnceWithExactly(mockScopedCollection.replace, 'NEW_VALUE', 'new'); + sinon.assert.neverCalledWith(mockScopedCollection.replace, 'OLD_VALUE', 'old'); + }); + }); + test('should NOT inject when useEnvFile is false even with python.envFile configured', async () => { getConfigurationStub.returns( createMockConfig({ diff --git a/src/test/integration/envCreation.integration.test.ts b/src/test/integration/envCreation.integration.test.ts index eeec3471d..4d1afac27 100644 --- a/src/test/integration/envCreation.integration.test.ts +++ b/src/test/integration/envCreation.integration.test.ts @@ -22,7 +22,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { ENVS_EXTENSION_ID } from '../constants'; -import { sleep, waitForCondition } from '../testUtils'; +import { runWithBlockedDialogsAsCancellation, sleep, waitForCondition } from '../testUtils'; suite('Integration: Environment Creation', function () { this.timeout(120_000); // Environment creation can be slow @@ -77,7 +77,9 @@ suite('Integration: Environment Creation', function () { try { // --- ACTION: User creates environment --- - createdEnv = await api.createEnvironment(workspaceUri, { quickCreate: true }); + createdEnv = await runWithBlockedDialogsAsCancellation(() => + api.createEnvironment(workspaceUri, { quickCreate: true }), + ); if (!createdEnv) { console.log('Environment creation returned undefined (may require user input)'); @@ -132,7 +134,9 @@ suite('Integration: Environment Creation', function () { let createdEnv: PythonEnvironment | undefined; try { - createdEnv = await api.createEnvironment(workspaceUri, { quickCreate: true }); + createdEnv = await runWithBlockedDialogsAsCancellation(() => + api.createEnvironment(workspaceUri, { quickCreate: true }), + ); if (!createdEnv) { this.skip(); return; @@ -178,7 +182,9 @@ suite('Integration: Environment Creation', function () { try { // Attempt global creation - this may prompt for user input // so we use quickCreate and expect it might return undefined - createdEnv = await api.createEnvironment('global', { quickCreate: true }); + createdEnv = await runWithBlockedDialogsAsCancellation(() => + api.createEnvironment('global', { quickCreate: true }), + ); if (createdEnv) { // If creation succeeded, verify the environment has valid structure @@ -230,7 +236,9 @@ suite('Integration: Environment Creation', function () { let createdEnv: PythonEnvironment | undefined; try { - createdEnv = await api.createEnvironment(workspaceUri, { quickCreate: true }); + createdEnv = await runWithBlockedDialogsAsCancellation(() => + api.createEnvironment(workspaceUri, { quickCreate: true }), + ); if (!createdEnv) { this.skip(); diff --git a/src/test/integration/envDiscovery.integration.test.ts b/src/test/integration/envDiscovery.integration.test.ts index 12efb35a5..76763b67c 100644 --- a/src/test/integration/envDiscovery.integration.test.ts +++ b/src/test/integration/envDiscovery.integration.test.ts @@ -112,12 +112,17 @@ suite('Integration: Environment Discovery', function () { // Trigger refresh - this should fire events for discovered environments await api.refreshEnvironments(undefined); - // Wait for events to propagate (discovery is async) - await handler.assertFiredAtLeast(1, 10_000); + // Managers refresh concurrently, and one without environments fires an empty change, + // so wait for the first event that reports environments (discovery is async). + await waitForCondition( + () => handler.all.some((e) => e.length > 0), + 10_000, + 'onDidChangeEnvironments did not report any environments during refresh', + ); // Verify event has valid structure // DidChangeEnvironmentsEventArgs is an array of {kind, environment} - const events = handler.first; + const events = handler.all.find((e) => e.length > 0); assert.ok(events, 'Event should have a value'); assert.ok(Array.isArray(events), 'Event should be an array'); assert.ok(events.length > 0, 'Should have received environment change events'); diff --git a/src/test/integration/environmentFixture.ts b/src/test/integration/environmentFixture.ts index 6e4261f13..626966d2b 100644 --- a/src/test/integration/environmentFixture.ts +++ b/src/test/integration/environmentFixture.ts @@ -18,6 +18,7 @@ const OWNERSHIP_FILE_NAME = '.python-envs-test-owner.json'; const COMMAND_TIMEOUT_MS = 180_000; const DISCOVERY_TIMEOUT_MS = 60_000; const API_REMOVAL_SETTLE_TIMEOUT_MS = 10_000; +const CONDA_FIXTURE_PYTHON_VERSION = '3.12'; export interface EnvironmentFixtureProvider { readonly environmentDirectory: string; @@ -312,16 +313,12 @@ export function createCondaFixtureProvider(): EnvironmentFixtureProvider { environmentDirectory: '.conda', managerId: CONDA_MANAGER_ID, create: async (_api, prefix) => { - const version = await runFixtureCommand('python', [ - '-c', - 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")', - ]); await runFixtureCommand(await getCondaExecutable(), [ 'create', '--yes', '--prefix', prefix.fsPath, - `python=${version.stdout.trim()}`, + `python=${CONDA_FIXTURE_PYTHON_VERSION}`, ]); return undefined; }, diff --git a/src/test/integration/inlineScriptCodeLens.integration.test.ts b/src/test/integration/inlineScriptCodeLens.integration.test.ts new file mode 100644 index 000000000..16e892e43 --- /dev/null +++ b/src/test/integration/inlineScriptCodeLens.integration.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { readInlineScriptMetadata } from '../../common/inlineScript/metadata'; +import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; +import { InlineScriptCodeLensProvider, registerInlineScriptCodeLens } from '../../features/inlineScript/codeLens'; +import { registerInlineScriptDiagnostics } from '../../features/inlineScript/diagnostics'; +import { InlineScriptLazyDetector } from '../../features/inlineScript/lazyDetector'; +import { sleep, waitForCondition } from '../testUtils'; + +const SETUP_COMMAND = 'python-envs.test.inlineScriptLensSetup'; +const SCRIPT = '# /// script\n# dependencies = []\n# ///\n\nprint("hello")\n'; + +suite('Integration: Live inline script CodeLens', function () { + this.timeout(30_000); + + let root: string; + let document: vscode.TextDocument; + let routing: InlineScriptRoutingRegistry; + let provider: InlineScriptCodeLensProvider; + let registration: vscode.Disposable; + let diagnostics: vscode.Disposable; + let detector: InlineScriptLazyDetector; + let nextFile = 0; + + suiteSetup(async () => { + const workspace = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspace, 'Inline CodeLens integration tests require an open workspace'); + root = await fs.mkdtemp(path.join(workspace.uri.fsPath, 'pep723-lens-integration-')); + }); + + setup(async () => { + routing = new InlineScriptRoutingRegistry(); + const codeLens = registerInlineScriptCodeLens(routing, SETUP_COMMAND); + provider = codeLens.provider; + registration = codeLens.disposable; + diagnostics = registerInlineScriptDiagnostics(); + detector = new InlineScriptLazyDetector(routing); + detector.activate(); + const uri = vscode.Uri.file(path.join(root, `script-${nextFile++}.py`)); + await fs.writeFile(uri.fsPath, SCRIPT); + document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document, { preview: false }); + await waitForCondition( + () => routing.getMetadata(document.uri) !== undefined, + 5_000, + 'The detector should read the saved script before editing', + ); + }); + + teardown(async () => { + registration.dispose(); + diagnostics.dispose(); + detector.dispose(); + routing.dispose(); + await vscode.window.showTextDocument(document); + await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor'); + }); + + suiteTeardown(async () => { + await fs.rm(root, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }); + }); + + async function lenses(): Promise { + const all = await vscode.commands.executeCommand( + 'vscode.executeCodeLensProvider', + document.uri, + ); + return (all ?? []).filter( + (lens) => + lens.command?.command === SETUP_COMMAND || lens.command?.title.startsWith('Script environment ready'), + ); + } + + async function replace(text: string): Promise { + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range(document.positionAt(0), document.positionAt(document.getText().length)), + text, + ); + assert.strictEqual(await vscode.workspace.applyEdit(edit), true); + } + + async function replaceAndWaitForLensRefresh(text: string): Promise { + let refreshed = false; + const subscription = provider.onDidChangeCodeLenses(() => { + refreshed = true; + }); + try { + await replace(text); + await waitForCondition(() => refreshed, 5_000, 'The CodeLens provider should refresh after the edit'); + } finally { + subscription.dispose(); + } + } + + function validateSavedEnvironment(version = '3.12.4'): void { + const metadata = readInlineScriptMetadata(document.getText()); + assert.ok(metadata); + routing.setMetadata(document.uri, metadata); + routing.setValidatedAssociation(document.uri, true, version); + } + + test('offers setup for unsaved malformed metadata while diagnostics remain visible', async () => { + await replace('# /// script\n# dependencies = [\n# ///\n'); + assert.strictEqual(document.isDirty, true); + const found = await lenses(); + assert.strictEqual(found.length, 1); + assert.strictEqual(found[0].command?.command, SETUP_COMMAND); + await waitForCondition( + () => + vscode.languages.getDiagnostics(document.uri).some((diagnostic) => diagnostic.code === 'invalid-toml'), + 5_000, + 'Malformed inline metadata should retain its diagnostic', + ); + }); + + test('retains setup for an unfinished marker block before any save', async () => { + await replace('# /// script'); + assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND); + await waitForCondition( + () => + vscode.languages.getDiagnostics(document.uri).some((diagnostic) => diagnostic.code === 'unterminated-block'), + 5_000, + 'Unfinished inline metadata should retain its diagnostic', + ); + }); + + test('keeps a validated ready label beyond the former five-second expiry', async () => { + validateSavedEnvironment(); + assert.strictEqual((await lenses())[0]?.command?.title, 'Script environment ready (Python 3.12.4)'); + await sleep(5_200); + const found = await lenses(); + assert.strictEqual(found.length, 1); + assert.strictEqual(found[0].command?.command, ''); + assert.strictEqual(found[0].command?.title, 'Script environment ready (Python 3.12.4)'); + }); + + test('body edits keep ready while block edits offer setup and Undo restores ready without saving', async () => { + validateSavedEnvironment(); + await replace(SCRIPT.replace('hello', 'dirty body')); + assert.strictEqual(document.isDirty, true); + assert.strictEqual((await lenses())[0]?.command?.command, ''); + await replaceAndWaitForLensRefresh(SCRIPT.replace('[]', '["requests"]')); + assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND); + assert.strictEqual(routing.shouldRoute(document.uri), false, 'the detector must still invalidate changed headers'); + await replace(SCRIPT); + assert.strictEqual((await lenses())[0]?.command?.command, ''); + assert.strictEqual(routing.shouldRoute(document.uri), true, 'Undo must restore the validated route'); + }); + + test('a dirty ordinary file gains its setup lens without being saved', async () => { + await replace('print("ordinary Python")\n'); + assert.deepStrictEqual(await lenses(), []); + + await replaceAndWaitForLensRefresh(SCRIPT); + + assert.strictEqual(document.isDirty, true); + assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND); + }); + + test('restored associations show the version and availability changes switch the label', async () => { + validateSavedEnvironment('3.13.2'); + registration.dispose(); + registration = registerInlineScriptCodeLens(routing, SETUP_COMMAND).disposable; + assert.strictEqual((await lenses())[0]?.command?.title, 'Script environment ready (Python 3.13.2)'); + routing.setEnvironmentUnavailable(document.uri, true); + assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND); + routing.setEnvironmentUnavailable(document.uri, false); + assert.strictEqual((await lenses())[0]?.command?.command, ''); + }); + + test('ordinary files and removed blocks have no inline CodeLens', async () => { + validateSavedEnvironment(); + await replace('print("ordinary Python")\n'); + assert.deepStrictEqual(await lenses(), []); + }); + + test('a saved and revalidated block restores ready without an expiry timer', async () => { + validateSavedEnvironment(); + await replace(SCRIPT.replace('dependencies = []', 'dependencies=[]')); + assert.strictEqual((await lenses())[0]?.command?.command, SETUP_COMMAND); + assert.strictEqual(await document.save(), true); + validateSavedEnvironment(); + assert.strictEqual((await lenses())[0]?.command?.command, ''); + }); + + test('compares a real normalized text document against its mixed-ending disk metadata', async () => { + await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor'); + const uri = vscode.Uri.file(path.join(root, 'mixed-endings.py')); + const disk = '# /// script\r\n# dependencies = []\n# ///\rprint("hello")'; + await fs.writeFile(uri.fsPath, disk); + document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document); + assert.notStrictEqual(document.getText(), disk, 'VS Code should normalize the mixed line endings'); + const metadata = readInlineScriptMetadata(await fs.readFile(uri.fsPath, 'utf8')); + assert.ok(metadata); + routing.setMetadata(uri, metadata); + routing.setValidatedAssociation(uri, true, '3.12.4'); + assert.strictEqual((await lenses())[0]?.command?.title, 'Script environment ready (Python 3.12.4)'); + }); +}); diff --git a/src/test/integration/multiroot/multiWorkspace.integration.test.ts b/src/test/integration/multiroot/multiWorkspace.integration.test.ts index 0e0c138b7..4da058f20 100644 --- a/src/test/integration/multiroot/multiWorkspace.integration.test.ts +++ b/src/test/integration/multiroot/multiWorkspace.integration.test.ts @@ -22,7 +22,7 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; import { ENVS_EXTENSION_ID } from '../../constants'; -import { waitForCondition } from '../../testUtils'; +import { runWithBlockedDialogsAsCancellation, waitForCondition } from '../../testUtils'; suite('Integration: Multi-Root Workspace', function () { this.timeout(120_000); @@ -175,7 +175,9 @@ suite('Integration: Multi-Root Workspace', function () { try { // This may prompt for manager selection - quickCreate should handle it - createdEnv = await api.createEnvironment(uris, { quickCreate: true }); + createdEnv = await runWithBlockedDialogsAsCancellation(() => + api.createEnvironment(uris, { quickCreate: true }), + ); if (createdEnv) { // Verify created environment has valid structure diff --git a/src/test/internal/discoveryIsolation.unit.test.ts b/src/test/internal/discoveryIsolation.unit.test.ts new file mode 100644 index 000000000..ac9c9d00f --- /dev/null +++ b/src/test/internal/discoveryIsolation.unit.test.ts @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, Uri } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { createDeferred, Deferred } from '../../common/utils/deferred'; +import { isSameOrParentPath } from '../../common/utils/pathUtils'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import type { PythonProjectManager } from '../../features/projectManager'; +import { pythonToolSupport, ToolEnvironmentManager, waitForToolRead } from '../../internal/pythonToolSupport'; +import * as cache from '../../managers/builtin/cache'; +import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; +import * as systemUtils from '../../managers/builtin/utils'; +import * as uvInstaller from '../../managers/builtin/uvPythonInstaller'; +import { VenvManager } from '../../managers/builtin/venvManager'; +import * as venvUtils from '../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; +import * as commonUtils from '../../managers/common/utils'; +import { CondaEnvManager } from '../../managers/conda/condaEnvManager'; +import * as condaUtils from '../../managers/conda/condaUtils'; +import { PipenvManager } from '../../managers/pipenv/pipenvManager'; +import * as pipenvUtils from '../../managers/pipenv/pipenvUtils'; +import { PoetryManager } from '../../managers/poetry/poetryManager'; +import * as poetryUtils from '../../managers/poetry/poetryUtils'; +import { PyEnvManager } from '../../managers/pyenv/pyenvManager'; +import * as pyenvUtils from '../../managers/pyenv/pyenvUtils'; +import { createMockLogOutputChannel } from '../mocks/helper'; +import { createMockPythonEnvironment } from '../mocks/pythonEnvironment'; + +interface TestManager extends ToolEnvironmentManager { + initialize(): Promise; +} + +suite('Agent discovery isolation from public onboarding', () => { + let source: CancellationTokenSource; + let api: PythonEnvironmentApi; + let entered: Deferred; + let release: Deferred; + let prompt: sinon.SinonStub; + let notify: sinon.SinonStub; + const finder = {} as NativePythonFinder; + const projects = {} as PythonProjectManager; + const scope = Uri.file(path.join(process.cwd(), 'discovery-isolation')); + const cases: { name: string; create: () => TestManager }[] = [ + { + name: 'system', + create: () => new SysPythonManager(finder, api, createMockLogOutputChannel()), + }, + { + name: 'venv', + create: () => + new VenvManager( + finder, + api, + new SysPythonManager(finder, api, createMockLogOutputChannel()), + createMockLogOutputChannel(), + ), + }, + { + name: 'conda', + create: () => new CondaEnvManager(finder, api, createMockLogOutputChannel(), projects), + }, + { name: 'poetry', create: () => new PoetryManager(finder, api, projects) }, + { name: 'pipenv', create: () => new PipenvManager(finder, api, projects) }, + { name: 'pyenv', create: () => new PyEnvManager(finder, api, projects) }, + ]; + + setup(() => { + source = new CancellationTokenSource(); + entered = createDeferred(); + release = createDeferred(); + api = { + getPythonProject: () => undefined, + getPythonProjects: () => [], + } as Partial as PythonEnvironmentApi; + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (_key: string, fallback?: T) => fallback as T, + has: () => false, + inspect: () => undefined, + update: async () => {}, + }); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task({ report: () => {} }, source.token), + ); + const wait = () => { + entered.resolve(); + return release.promise; + }; + prompt = sinon.stub(uvInstaller, 'promptInstallPythonViaUv').callsFake(async () => { + await wait(); + return undefined; + }); + notify = sinon.stub(commonUtils, 'notifyMissingManagerIfDefault').callsFake(wait); + sinon.stub(cache, 'getSystemEnvForGlobal').resolves(undefined); + sinon.stub(cache, 'getSystemEnvForWorkspace').resolves(undefined); + sinon.stub(systemUtils, 'refreshPythons').resolves([]); + sinon.stub(systemUtils, 'resolveSystemPythonEnvironmentPath').resolves(undefined); + sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([]); + sinon.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + sinon.stub(venvUtils, 'getVenvForWorkspace').resolves(undefined); + sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(undefined); + sinon.stub(condaUtils, 'getConda').rejects(new Error('Conda is not installed')); + sinon.stub(condaUtils, 'getCondaPathSetting').returns(undefined); + sinon.stub(condaUtils, 'refreshCondaEnvs').resolves([]); + sinon.stub(condaUtils, 'getCondaForGlobal').resolves(undefined); + sinon.stub(condaUtils, 'resolveCondaPath').resolves(undefined); + sinon.stub(poetryUtils, 'getPoetry').resolves(undefined); + sinon.stub(poetryUtils, 'refreshPoetry').resolves([]); + sinon.stub(poetryUtils, 'getPoetryForGlobal').resolves(undefined); + sinon.stub(poetryUtils, 'resolvePoetryPath').resolves(undefined); + sinon.stub(pipenvUtils, 'getPipenv').resolves(undefined); + sinon.stub(pipenvUtils, 'refreshPipenv').resolves([]); + sinon.stub(pipenvUtils, 'getPipenvForGlobal').resolves(undefined); + sinon.stub(pipenvUtils, 'resolvePipenvPath').resolves(undefined); + sinon.stub(pyenvUtils, 'getPyenv').resolves(undefined); + sinon.stub(pyenvUtils, 'refreshPyenv').resolves([]); + sinon.stub(pyenvUtils, 'getPyenvForGlobal').resolves(undefined); + sinon.stub(pyenvUtils, 'resolvePyenvPath').resolves(undefined); + }); + + teardown(() => { + release.resolve(); + source.dispose(); + sinon.restore(); + }); + + async function readWithoutOnboarding(manager: TestManager): Promise { + const tools = manager[pythonToolSupport]; + await waitForToolRead(tools.initialize(), source.token, 1000); + assert.strictEqual(await waitForToolRead(tools.get(scope), source.token, 1000), undefined); + assert.deepStrictEqual(await waitForToolRead(tools.getEnvironments('global'), source.token, 1000), []); + assert.strictEqual(await waitForToolRead(tools.resolve(scope), source.token, 1000), undefined); + } + + for (const scenario of cases) { + test(`${scenario.name}: private reads do not suppress later public onboarding`, async () => { + const manager = scenario.create(); + await readWithoutOnboarding(manager); + assert.ok(prompt.notCalled && notify.notCalled); + const human = manager.initialize(); + try { + await waitForToolRead(entered.promise, source.token, 1000); + await readWithoutOnboarding(manager); + } finally { + release.resolve(); + await human; + } + await manager.initialize(); + assert.strictEqual(prompt.callCount + notify.callCount, 1); + }); + + test(`${scenario.name}: private reads finish while public onboarding is still pending`, async () => { + const manager = scenario.create(); + let humanFinished = false; + const human = manager.initialize().then(() => { + humanFinished = true; + }); + try { + await waitForToolRead(entered.promise, source.token, 1000); + await readWithoutOnboarding(manager); + assert.strictEqual(humanFinished, false); + assert.strictEqual(prompt.callCount + notify.callCount, 1); + } finally { + release.resolve(); + await human; + } + }); + } + + test('venv private reads do not join the public fast path waiting on base Python onboarding', async () => { + const manager = cases[1].create(); + let humanFinished = false; + const human = manager.get(scope).then(() => { + humanFinished = true; + }); + try { + await waitForToolRead(entered.promise, source.token, 1000); + await readWithoutOnboarding(manager); + assert.strictEqual(humanFinished, false); + } finally { + release.resolve(); + await human; + } + }); + + suite('venv public initialization recovery', () => { + let manager: VenvManager; + let base: PythonEnvironment; + let local: PythonEnvironment; + let discovery: sinon.SinonStub; + let persistence: sinon.SinonStub; + const failure = new Error('Unable to persist the installed Python selection'); + + setup(() => { + const project = { name: 'workspace', uri: scope }; + sinon + .stub(api, 'getPythonProject') + .callsFake((uri) => (isSameOrParentPath(scope.fsPath, uri.fsPath) ? project : undefined)); + sinon.stub(api, 'getPythonProjects').returns([project]); + base = createMockPythonEnvironment({ + name: 'base', + managerId: SYSTEM_MANAGER_ID, + envPath: path.join(path.dirname(scope.fsPath), 'base-python', 'python'), + }); + local = createMockPythonEnvironment({ + name: 'local', + managerId: VENV_MANAGER_ID, + envPath: path.join(scope.fsPath, '.venv', process.platform === 'win32' ? 'Scripts' : 'bin', 'python'), + sysPrefix: path.join(scope.fsPath, '.venv'), + }); + prompt.resolves(base.environmentPath.fsPath); + (systemUtils.resolveSystemPythonEnvironmentPath as sinon.SinonStub).resolves(base); + persistence = sinon.stub(cache, 'setSystemEnvForGlobal').rejects(failure); + discovery = venvUtils.findVirtualEnvironments as sinon.SinonStub; + manager = new VenvManager( + finder, + api, + new SysPythonManager(finder, api, createMockLogOutputChannel()), + createMockLogOutputChannel(), + ); + }); + + test('public get rediscovers local environments after the base initialization step fails', async () => { + await manager.get(scope); + assert.ok(persistence.calledOnce); + assert.ok(discovery.calledOnce); + discovery.resolves([local]); + + assert.strictEqual(await manager.get(scope), local); + assert.deepStrictEqual(await manager.getEnvironments('all'), [local]); + assert.ok(discovery.calledTwice); + assert.ok(prompt.calledOnce, 'A successful base installation must not be requested again'); + }); + + for (const entry of ['initialize', 'getEnvironments'] as const) { + test(`${entry} retries discovery after a base initialization failure`, async () => { + const initialize = () => + entry === 'initialize' ? manager.initialize() : manager.getEnvironments('all'); + await assert.rejects(initialize(), (error) => error === failure); + discovery.resolves([local]); + + await initialize(); + assert.strictEqual(await manager.get(scope), local); + assert.deepStrictEqual(await manager.getEnvironments('all'), [local]); + assert.ok(discovery.calledTwice); + }); + } + + test('direct initialization retries after discovery itself fails', async () => { + (systemUtils.refreshPythons as sinon.SinonStub).resolves([base]); + discovery.onFirstCall().rejects(failure); + discovery.resolves([local]); + await assert.rejects(manager.initialize(), (error) => error === failure); + + await manager.initialize(); + assert.strictEqual(await manager.get(scope), local); + assert.ok(discovery.calledTwice); + assert.ok(prompt.notCalled); + }); + + test('public get retries when loading the global selection after discovery fails', async () => { + (systemUtils.refreshPythons as sinon.SinonStub).resolves([base]); + (venvUtils.getVenvForGlobal as sinon.SinonStub).onSecondCall().rejects(failure); + await manager.get(scope); + discovery.resolves([local]); + + assert.strictEqual(await manager.get(scope), local); + assert.ok(discovery.calledTwice); + assert.ok(prompt.notCalled); + }); + + test('successful initialization remains cached across public reads', async () => { + (systemUtils.refreshPythons as sinon.SinonStub).resolves([base]); + discovery.resolves([local]); + await manager.initialize(); + await manager.initialize(); + + assert.strictEqual(await manager.get(scope), local); + assert.deepStrictEqual(await manager.getEnvironments('all'), [local]); + assert.ok(discovery.calledOnce); + }); + + test('private discovery stays independent of failed human onboarding and supports the later public retry', async () => { + prompt.callsFake(async () => { + entered.resolve(); + await release.promise; + return base.environmentPath.fsPath; + }); + const human = manager.initialize().then( + () => undefined, + (error: unknown) => error, + ); + await waitForToolRead(entered.promise, source.token, 1000); + try { + await manager[pythonToolSupport].initialize(); + assert.strictEqual(await manager[pythonToolSupport].get(scope), undefined); + assert.ok(discovery.calledOnce); + } finally { + release.resolve(); + } + assert.strictEqual(await human, failure); + discovery.resolves([local]); + + await manager[pythonToolSupport].initialize(); + assert.strictEqual(await manager[pythonToolSupport].get(scope), local); + await manager.initialize(); + assert.strictEqual(await manager.get(scope), local); + assert.ok(discovery.calledTwice); + assert.ok(prompt.calledOnce); + }); + }); +}); diff --git a/src/test/internal/processRunner.unit.test.ts b/src/test/internal/processRunner.unit.test.ts new file mode 100644 index 000000000..b3e866cbb --- /dev/null +++ b/src/test/internal/processRunner.unit.test.ts @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as cp from 'child_process'; +import * as sinon from 'sinon'; +import { CancellationError, CancellationTokenSource } from 'vscode'; +import * as childProcesses from '../../common/childProcess.apis'; +import { createDeferred } from '../../common/utils/deferred'; +import * as platformUtils from '../../common/utils/platformUtils'; +import { + PROCESS_TERMINATION_TIMEOUT_MS, + ProcessTerminationError, + ProcessTimeoutError, + runLoggedProcess, +} from '../../common/utils/processRunner'; +import { MockChildProcess } from '../mocks/mockChildProcess'; + +suite('Python tool process ownership', () => { + let source: CancellationTokenSource; + + setup(() => { + source = new CancellationTokenSource(); + }); + teardown(() => { + source.dispose(); + sinon.restore(); + }); + + test('does not spawn when already cancelled', async () => { + const spawn = sinon.stub(childProcesses, 'spawnProcess'); + source.cancel(); + await assert.rejects(runLoggedProcess('python', [], {}, undefined, source.token), CancellationError); + assert.ok(spawn.notCalled); + }); + + test('distinguishes timeout and disposes the cancellation subscription on completion', async () => { + const clock = sinon.useFakeTimers(); + const process = new MockChildProcess('python', []); + const kill = sinon.stub(process, 'kill').callsFake(() => { + process.emit('exit', null, 'SIGTERM'); + process.emit('close', null, 'SIGTERM'); + return true; + }); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.returns(process); + const pending = assert.rejects( + runLoggedProcess('python', [], {}, undefined, source.token, 50), + ProcessTimeoutError, + ); + await clock.tickAsync(51); + await pending; + source.cancel(); + assert.ok(kill.calledOnce); + }); + + test('reports failed termination explicitly instead of claiming cancellation completed', async () => { + const process = new MockChildProcess('python', []); + sinon.stub(process, 'kill').returns(false); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.returns(process); + const pending = runLoggedProcess('python', [], {}, undefined, source.token); + source.cancel(); + await assert.rejects( + pending, + (error: unknown) => + error instanceof ProcessTerminationError && error.operationError instanceof CancellationError, + ); + }); + + test('does not signal a reused Windows pid or claim its descendants were cleaned up', async () => { + sinon.stub(platformUtils, 'isWindows').returns(true); + const process = new MockChildProcess('python', []); + Object.defineProperty(process, 'pid', { value: 1234 }); + Object.defineProperty(process, 'exitCode', { value: 0 }); + const kill = sinon.stub(process, 'kill').returns(true); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.returns(process); + const pending = runLoggedProcess('python', [], {}, undefined, source.token); + let completed = false; + void pending.then( + () => { + completed = true; + }, + () => { + completed = true; + }, + ); + source.cancel(); + await Promise.resolve(); + assert.strictEqual(completed, false); + process.emit('close', 0, null); + await assert.rejects( + pending, + (error: unknown) => + error instanceof ProcessTerminationError && + error.operationError instanceof CancellationError && + error.cause instanceof Error && + error.cause.message.includes('child-process cleanup cannot be confirmed'), + ); + assert.ok(spawn.calledOnce); + assert.ok(kill.notCalled); + }); + + test('does not treat an externally signalled process as successful', async () => { + const process = new MockChildProcess('python', []); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.returns(process); + const pending = runLoggedProcess('python', [], {}); + process.emit('exit', null, 'SIGTERM'); + process.emit('close', null, 'SIGTERM'); + await assert.rejects(pending, /SIGTERM/); + }); + + test('failed Windows tree termination stays a cleanup failure after the parent exits and closes', async () => { + sinon.stub(platformUtils, 'isWindows').returns(true); + const child = new MockChildProcess('python', []); + Object.defineProperty(child, 'pid', { value: 1234 }); + const killer = new MockChildProcess('taskkill.exe', []); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.onFirstCall().returns(child); + spawn.onSecondCall().returns(killer); + const pending = runLoggedProcess('python', [], {}, undefined, source.token); + const rejected = assert.rejects( + pending, + (error: unknown) => + error instanceof ProcessTerminationError && + error.operationError instanceof CancellationError && + error.cause instanceof Error && + error.cause.message.includes('taskkill exit 1'), + ); + source.cancel(); + Object.defineProperty(child, 'exitCode', { value: 0 }); + child.emit('exit', 0, null); + child.emit('close', 0, null); + killer.emit('close', 1, null); + await rejected; + assert.ok(spawn.secondCall.calledWith('taskkill.exe', ['/PID', '1234', '/T', '/F'])); + }); + + test('a nonzero exited parent retaining pipes keeps its timeout and reports bounded cleanup failure', async () => { + const clock = sinon.useFakeTimers(); + sinon.stub(platformUtils, 'isWindows').returns(true); + const process = new MockChildProcess('python', []); + Object.defineProperty(process, 'pid', { value: 1234 }); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.returns(process); + const pending = runLoggedProcess('python', [], {}, undefined, source.token, 50); + const rejected = assert.rejects( + pending, + (error: unknown) => + error instanceof ProcessTerminationError && error.operationError instanceof ProcessTimeoutError, + ); + Object.defineProperty(process, 'exitCode', { value: 1 }); + process.emit('exit', 1, null); + await clock.tickAsync(50 + PROCESS_TERMINATION_TIMEOUT_MS + 1); + await rejected; + assert.ok(spawn.calledOnce, 'Do not issue taskkill for an exited, potentially reused parent PID'); + }); + + test('POSIX cancellation waits for close and owned group disappearance even after its leader exits', async () => { + const clock = sinon.useFakeTimers(); + sinon.stub(platformUtils, 'isWindows').returns(false); + const child = new MockChildProcess('python', []); + Object.defineProperty(child, 'pid', { value: 1234 }); + Object.defineProperty(child, 'exitCode', { value: 0 }); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.returns(child); + let groupAlive = true; + const signal = sinon.stub(process, 'kill').callsFake((_pid, value) => { + if (value === 0 && !groupAlive) { + throw Object.assign(new Error('No such process group'), { code: 'ESRCH' }); + } + return true; + }); + const pending = runLoggedProcess('python', [], {}, undefined, source.token); + let completed = false; + void pending.then( + () => { + completed = true; + }, + () => { + completed = true; + }, + ); + source.cancel(); + await clock.tickAsync(1); + assert.ok(signal.calledWithExactly(-1234, 'SIGKILL')); + assert.strictEqual(completed, false); + child.emit('close', 0, null); + await clock.tickAsync(25); + assert.strictEqual(completed, false, 'Closed pipes alone do not prove the owned group has exited'); + groupAlive = false; + await clock.tickAsync(25); + await assert.rejects(pending, CancellationError); + assert.ok(signal.calledWithExactly(-1234, 0)); + }); + + test('stops a real owned subprocess before reporting cancellation', async function () { + this.timeout(15_000); + const spawned = createDeferred(); + sinon.stub(childProcesses, 'spawnProcess').callsFake((executable, args, options) => { + const child = cp.spawn(executable, args, options ?? {}); + if (executable === process.execPath) { + child.on('spawn', () => spawned.resolve(child)); + child.on('error', (error) => spawned.reject(error)); + } + return child; + }); + const pending = runLoggedProcess( + process.execPath, + ['-e', 'setInterval(() => {}, 1000)'], + {}, + undefined, + source.token, + ); + const child = await spawned.promise; + let closed = false; + child.on('close', () => { + closed = true; + }); + try { + source.cancel(); + await assert.rejects(pending, CancellationError); + assert.ok(closed, 'Cancellation must not resolve before the real close event'); + assert.ok(child.exitCode !== null || child.signalCode !== null); + } finally { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + } + }); + + (process.platform === 'win32' ? test.skip : test)( + 'real POSIX exited parent with a live child retaining pipes cannot report an early successful cleanup', + async function () { + this.timeout(20_000); + const parentExited = createDeferred(); + const descendantPid = createDeferred(); + let parent: cp.ChildProcess | undefined; + let closed = false; + sinon.stub(childProcesses, 'spawnProcess').callsFake((executable, args, options) => { + const child = cp.spawn(executable, args, options ?? {}); + if (executable === process.execPath) { + parent = child; + let output = ''; + child.stdout?.on('data', (data) => { + output += data.toString(); + const match = /CHILD:(\d+)/.exec(output); + if (match) { + descendantPid.resolve(Number(match[1])); + } + }); + child.on('exit', () => parentExited.resolve()); + child.on('close', () => { + closed = true; + }); + } + return child; + }); + const script = [ + "const child = require('child_process').spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'],", + "{ stdio: ['ignore', process.stdout, process.stderr] });", + "child.on('spawn', () => { child.unref(); process.stdout.write('CHILD:' + child.pid + '\\n', () => process.exit(1)); });", + ].join('\n'); + const pending = runLoggedProcess(process.execPath, ['-e', script], {}, undefined, source.token); + let pid: number | undefined; + try { + pid = await descendantPid.promise; + await parentExited.promise; + assert.strictEqual(closed, false, 'The child must still own the inherited output pipes'); + source.cancel(); + await assert.rejects(pending, CancellationError); + assert.strictEqual(closed, true); + } finally { + if (pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ESRCH')) { + throw error; + } + } + } + if (parent && !closed) { + await new Promise((resolve) => parent!.once('close', () => resolve())); + } + } + }, + ); +}); diff --git a/src/test/internal/publicApiCompatibility.unit.test.ts b/src/test/internal/publicApiCompatibility.unit.test.ts new file mode 100644 index 000000000..6bbc4f4ab --- /dev/null +++ b/src/test/internal/publicApiCompatibility.unit.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { ChildProcess, type SpawnOptions } from 'child_process'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { PassThrough } from 'stream'; +import { CancellationTokenSource, Disposable, EventEmitter, FileType, Uri } from 'vscode'; +import { EnvironmentManager, PackageManager, PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import * as childProcessApis from '../../common/childProcess.apis'; +import * as persistentState from '../../common/persistentState'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import * as workspaceFs from '../../common/workspace.fs.apis'; +import { PythonEnvironmentApiImpl } from '../../extensionApi'; +import * as managerReady from '../../features/common/managerReady'; +import { pythonToolSupport } from '../../internal/pythonToolSupport'; +import * as helpers from '../../managers/builtin/helpers'; +import { PipPackageManager } from '../../managers/builtin/pipPackageManager'; +import { VenvManager } from '../../managers/builtin/venvManager'; +import * as packageChanges from '../../managers/common/packageChanges'; +import { CondaPackageManager } from '../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../managers/conda/condaUtils'; +import { PoetryManager } from '../../managers/poetry/poetryManager'; +import { PoetryPackageManager } from '../../managers/poetry/poetryPackageManager'; +import * as poetryUtils from '../../managers/poetry/poetryUtils'; +import { createMockLogOutputChannel } from '../mocks/helper'; + +interface SpawnRecord { + executable: string; + args: string[]; + options: SpawnOptions; + endInput: sinon.SinonSpy; +} + +suite('Public package API execution policy', () => { + let source: CancellationTokenSource; + let temp: string; + let scope: Uri; + let environment: PythonEnvironment; + let calls: SpawnRecord[]; + let progress: sinon.SinonStub; + let timers: sinon.SinonSpy; + let disposables: Disposable[]; + + setup(async () => { + source = new CancellationTokenSource(); + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'python-api-compat-')); + scope = Uri.file(temp); + const prefix = path.join(temp, '.venv'); + await fs.ensureDir(prefix); + const executable = path.join(prefix, process.platform === 'win32' ? 'python.exe' : 'python'); + environment = { + envId: { id: 'test', managerId: 'ms-python.python:venv' }, + name: 'test', + displayName: 'test', + displayPath: executable, + environmentPath: Uri.file(prefix), + sysPrefix: prefix, + execInfo: { run: { executable } }, + version: '3.13.0', + }; + const conda = path.join(temp, 'conda'); + await fs.outputFile(conda, ''); + await condaUtils.clearCondaCache(); + sinon.stub(persistentState, 'getWorkspacePersistentState').resolves({ + get: sinon.stub().resolves(conda), + set: sinon.stub().resolves(), + clear: sinon.stub().resolves(), + }); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string, fallback?: T): T => (key === 'condaPath' ? conda : fallback) as T, + inspect: () => undefined, + has: () => false, + update: async () => {}, + }); + sinon.stub(poetryUtils, 'getPoetry').resolves(path.join(temp, 'poetry')); + sinon.stub(workspaceFs, 'stat').resolves({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }); + sinon.stub(managerReady, 'waitForEnvManagerId').resolves(); + sinon.stub(packageChanges, 'updatePackagesAndNotify').resolves([]); + progress = sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task({ report: () => {} }, source.token), + ); + calls = []; + disposables = []; + sinon.stub(childProcessApis, 'spawnProcess').callsFake((command, args, options) => { + const child = Object.assign(new ChildProcess(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + stdin: new PassThrough(), + }); + const endInput = sinon.spy(child.stdin, 'end'); + calls.push({ executable: command, args, options: options ?? {}, endInput }); + setImmediate(() => { + const output = args.includes('info') ? prefix : args.includes('list') ? '[]' : ''; + child.stdout.emit('data', Buffer.from(output)); + child.emit('exit', 0, null); + child.emit('close', 0, null); + }); + return child; + }); + timers = sinon.spy(global, 'setTimeout'); + }); + + teardown(async () => { + disposables.forEach((item) => item.dispose()); + source.dispose(); + sinon.restore(); + await condaUtils.clearCondaCache(); + await fs.remove(temp); + }); + + function publicApi(manager: PackageManager): PythonEnvironmentApi { + const event = () => { + const emitter = new EventEmitter(); + disposables.push(emitter); + return emitter.event; + }; + type Args = ConstructorParameters; + return new PythonEnvironmentApiImpl( + { + onDidChangeActiveEnvironment: event(), + onDidChangePackageProviderPackages: event(), + getPackageManager: () => manager, + } as unknown as Args[0], + { getProjects: () => [], onDidChangeProjects: event() } as unknown as Args[1], + {} as Args[2], + {} as Args[3], + { onDidChangeEnvironmentVariables: event() } as unknown as Args[4], + disposables, + ); + } + + for (const kind of ['pip', 'uv', 'conda', 'poetry'] as const) { + function manager() { + const api = {} as PythonEnvironmentApi; + const log = createMockLogOutputChannel(); + if (kind === 'conda') { + return new CondaPackageManager(api, log); + } + if (kind === 'poetry') { + return new PoetryPackageManager(api, log, {} as PoetryManager).createForProject({ + name: 'project', + uri: scope, + }); + } + sinon.stub(helpers, 'shouldUseUv').resolves(kind === 'uv'); + sinon.stub(helpers, 'getUvExecutable').resolves(path.join(temp, '.pyprojectx', 'main', 'uv')); + return new PipPackageManager(api, log, {} as VenvManager); + } + + test(`${kind}: public progress tokens do not enable agent process policy`, async () => { + await publicApi(manager()).managePackages(environment, { install: ['requests'] }); + assert.ok(progress.calledOnce); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].options.timeout, undefined); + assert.strictEqual(calls[0].options.env, undefined); + assert.strictEqual(calls[0].options.detached, undefined); + assert.ok(calls[0].endInput.notCalled); + assert.ok(!calls[0].args.includes('--no-interaction')); + assert.ok(!timers.getCalls().some((call) => call.args[1] === 300_000)); + if (kind === 'uv') { + assert.strictEqual(calls[0].executable, path.join(temp, '.pyprojectx', 'main', 'uv')); + } + }); + + test(`${kind}: private capabilities explicitly enable agent process policy`, async () => { + await manager()[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope); + assert.ok(progress.notCalled); + assert.ok(calls.length >= 1); + for (const call of calls) { + assert.strictEqual(call.options.env?.PIP_NO_INPUT, '1'); + assert.ok(call.endInput.calledOnce); + if (kind === 'poetry') { + assert.strictEqual(call.args[0], '--no-interaction'); + } + if (kind === 'uv') { + assert.strictEqual(call.executable, path.join(temp, '.pyprojectx', 'main', 'uv')); + } + } + if (kind === 'poetry') { + assert.ok(calls.some((call) => call.args.includes('install') && call.args.includes('--no-root'))); + } + assert.ok(timers.getCalls().some((call) => call.args[1] === 300_000)); + }); + } + + test('an existing explicit public process timeout is preserved without opting into tool policy', async () => { + await helpers.runPython('python', ['-c', 'pass'], temp, undefined, source.token, 42); + assert.strictEqual(calls[0].options.timeout, 42); + assert.strictEqual(calls[0].options.env, undefined); + assert.ok(calls[0].endInput.notCalled); + }); + + for (const toolExecution of [false, true]) { + test(`Conda quick-create ${toolExecution ? 'preserves the tool base version' : 'keeps public version selection unchanged'}`, async () => { + const prefix = path.join(temp, '.conda'); + await fs.outputJson(path.join(prefix, 'conda-meta', 'python-3.13.0-0.json'), { version: '3.13.0' }); + const api: Partial = { + createPythonEnvironmentItem: sinon.stub().returns(environment), + }; + await condaUtils.quickCreateConda( + api as PythonEnvironmentApi, + createMockLogOutputChannel(), + { name: 'conda' } as EnvironmentManager, + temp, + '.conda', + undefined, + toolExecution + ? { + token: source.token, + baseEnvironment: environment, + getGlobalEnvironments: sinon.stub().resolves([]), + managePackages: sinon.stub().resolves(), + } + : undefined, + ); + assert.strictEqual(calls.length, 1); + assert.strictEqual(calls[0].args.at(-1), toolExecution ? 'python=3.13' : 'python'); + assert.strictEqual(progress.callCount, toolExecution ? 0 : 1); + }); + } +}); diff --git a/src/test/internal/pythonToolManagers.unit.test.ts b/src/test/internal/pythonToolManagers.unit.test.ts new file mode 100644 index 000000000..a7ab83b67 --- /dev/null +++ b/src/test/internal/pythonToolManagers.unit.test.ts @@ -0,0 +1,450 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { CancellationError, CancellationTokenSource, FileType, Uri } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import * as workspaceFs from '../../common/workspace.fs.apis'; +import { normalizePath } from '../../common/utils/pathUtils'; +import { + PythonToolError, + pythonToolSupport, + resolveToolProjectEnvironment, +} from '../../internal/pythonToolSupport'; +import * as cache from '../../managers/builtin/cache'; +import { PipInstallCommand, PipListCommand } from '../../managers/builtin/commands'; +import * as helpers from '../../managers/builtin/helpers'; +import { PipPackageManager } from '../../managers/builtin/pipPackageManager'; +import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; +import * as builtinUtils from '../../managers/builtin/utils'; +import * as uvInstaller from '../../managers/builtin/uvPythonInstaller'; +import { VenvManager } from '../../managers/builtin/venvManager'; +import * as venvUtils from '../../managers/builtin/venvUtils'; +import { + NativeInfo, + NativePythonEnvironmentKind, + NativePythonFinder, +} from '../../managers/common/nativePythonFinder'; +import { PipenvManager } from '../../managers/pipenv/pipenvManager'; +import { CondaInstallCommand, CondaListCommand } from '../../managers/conda/commands'; +import { CondaEnvManager } from '../../managers/conda/condaEnvManager'; +import { CondaPackageManager } from '../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../managers/conda/condaUtils'; +import { PoetryAddCommand, PoetryShowCommand } from '../../managers/poetry/commands'; +import * as poetryRunner from '../../managers/poetry/commands/runPoetry'; +import { PoetryManager } from '../../managers/poetry/poetryManager'; +import { PoetryPackageManager } from '../../managers/poetry/poetryPackageManager'; +import * as poetryUtils from '../../managers/poetry/poetryUtils'; +import { createMockLogOutputChannel } from '../mocks/helper'; + +suite('Python tool manager capabilities', () => { + let source: CancellationTokenSource; + let api: PythonEnvironmentApi; + let environment: PythonEnvironment; + let scope: Uri; + let progress: sinon.SinonStub; + let errors: sinon.SinonStub; + let temp: string | undefined; + + setup(() => { + temp = undefined; + source = new CancellationTokenSource(); + scope = Uri.file(path.join(process.cwd(), 'project with spaces')); + const executable = path.join(scope.fsPath, '.venv', 'python.exe'); + environment = { + envId: { id: 'test', managerId: 'ms-python.python:venv' }, + name: 'test', + displayName: 'test', + displayPath: executable, + environmentPath: Uri.file(executable), + execInfo: { run: { executable } }, + sysPrefix: path.dirname(executable), + version: '3.12.4', + }; + const partialApi: Partial = { + createPackageItem: sinon.stub().callsFake((info) => info), + getPythonProjects: sinon.stub().returns([]), + }; + api = partialApi as PythonEnvironmentApi; + progress = sinon.stub(windowApis, 'withProgress').throws(new Error('Unexpected progress UI')); + errors = sinon.stub(windowApis, 'showErrorMessage').throws(new Error('Unexpected error question')); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (_key: string, defaultValue?: T): T => defaultValue as T, + inspect: () => undefined, + update: async () => {}, + has: () => false, + }); + }); + + teardown(async () => { + source.dispose(); + sinon.restore(); + if (temp) { + await fs.remove(temp); + } + }); + + test('pip mutations and their inventory refresh receive the tool token without public UI', async () => { + const pip = new PipPackageManager(api, createMockLogOutputChannel(), {} as VenvManager); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + const install = sinon.stub(PipInstallCommand.prototype, 'execute').resolves(); + const list = sinon.stub(PipListCommand.prototype, 'execute').resolves([]); + const direct = sinon.stub(pip, 'getDirectPackageNames').rejects(new Error('Unexpected unscoped query')); + await pip[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope); + assert.strictEqual(install.firstCall.args[0].cancellationToken, source.token); + assert.strictEqual(install.firstCall.args[0].toolExecution, true); + assert.strictEqual(Reflect.get(install.firstCall.thisValue, 'cwd'), scope.fsPath); + assert.strictEqual(list.firstCall.args[0]?.cancellationToken, source.token); + assert.strictEqual(list.firstCall.args[0]?.strict, true); + assert.strictEqual(list.firstCall.args[0]?.toolExecution, true); + assert.ok(direct.notCalled); + assert.ok(progress.notCalled && errors.notCalled); + }); + + test('pip private inventory rejects failure rather than returning stale packages', async () => { + const pip = new PipPackageManager(api, createMockLogOutputChannel(), {} as VenvManager); + sinon.stub(helpers, 'shouldUseUv').resolves(false); + const list = sinon + .stub(PipListCommand.prototype, 'execute') + .resolves([{ name: 'pip', displayName: 'pip', version: '25.0' }]); + await pip.getPackages(environment); + list.rejects(new Error('list failed')); + await assert.rejects(pip[pythonToolSupport].getPackages(environment, source.token, scope), /list failed/); + assert.ok(errors.notCalled); + }); + + test('pip strict list rejects malformed output but public list behavior is unchanged', async () => { + sinon.stub(helpers, 'runPython').resolves('not json'); + const command = new PipListCommand({ pythonExecutable: environment.execInfo.run.executable }); + await assert.rejects(command.execute({ strict: true, cancellationToken: source.token })); + assert.deepStrictEqual(await command.execute(), []); + }); + + test('Conda headless install and query use the real cancellation token', async () => { + const conda = new CondaPackageManager(api, createMockLogOutputChannel()); + const install = sinon.stub(CondaInstallCommand.prototype, 'execute').resolves(); + const list = sinon.stub(CondaListCommand.prototype, 'execute').resolves([]); + await conda[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope); + assert.strictEqual(install.firstCall.args[0].cancellationToken, source.token); + assert.strictEqual(install.firstCall.args[0].toolExecution, true); + assert.strictEqual(list.firstCall.args[0]?.cancellationToken, source.token); + assert.strictEqual(list.firstCall.args[0]?.toolExecution, true); + list.rejects(new Error('Conda list failed')); + await assert.rejects( + conda[pythonToolSupport].getPackages(environment, source.token, scope), + /Conda list failed/, + ); + assert.ok(progress.notCalled && errors.notCalled); + }); + + test('Conda private descriptors have standalone execution without mutating the shared descriptor', async () => { + const manager = new CondaEnvManager({} as NativePythonFinder, api, createMockLogOutputChannel()); + const executable = path.join(scope.fsPath, 'conda install', 'conda.exe'); + sinon.stub(condaUtils, 'getCondaForTools').resolves(executable); + const original = environment.execInfo; + const descriptor = await manager[pythonToolSupport].describe!(environment, source.token); + assert.notStrictEqual(descriptor, environment); + assert.strictEqual(environment.execInfo, original); + assert.strictEqual(environment.execInfo.activatedRun, undefined); + assert.deepStrictEqual(descriptor.execInfo.activatedRun, { + executable, + args: [ + 'run', + '--prefix', + environment.sysPrefix, + '--no-capture-output', + environment.execInfo.run.executable, + ], + }); + }); + + test('Conda tool descriptors resolve a bare configured command to an absolute PATH executable', async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'conda-tool-command-')); + const name = 'conda-tool-fixture'; + const commandDirectory = process.platform === 'win32' ? path.join(temp, 'condabin') : temp; + const command = path.join(commandDirectory, process.platform === 'win32' ? `${name}.cmd` : name); + const executable = + process.platform === 'win32' ? path.join(temp, 'Scripts', 'conda.exe') : command; + await fs.outputFile( + command, + process.platform === 'win32' ? '@echo off\r\nexit /b 0\r\n' : '#!/bin/sh\nexit 0\n', + ); + if (process.platform === 'win32') { + await fs.outputFile(executable, ''); + } + if (process.platform !== 'win32') { + await fs.chmod(command, 0o755); + } + const config = workspaceApis.getConfiguration('python'); + sinon + .stub(config, 'get') + .callsFake((key: string, fallback?: T) => (key === 'condaPath' ? name : fallback) as T); + const previousPath = process.env.PATH; + process.env.PATH = `${commandDirectory}${path.delimiter}${previousPath ?? ''}`; + try { + const manager = new CondaEnvManager({} as NativePythonFinder, api, createMockLogOutputChannel()); + const described = await manager[pythonToolSupport].describe!(environment, source.token); + assert.strictEqual(await condaUtils.getConda(), name, 'Public settings behavior is unchanged'); + assert.ok(path.isAbsolute(described.execInfo.activatedRun!.executable)); + assert.strictEqual(normalizePath(described.execInfo.activatedRun!.executable), normalizePath(executable)); + assert.strictEqual(environment.execInfo.activatedRun, undefined); + } finally { + if (previousPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = previousPath; + } + } + }); + + test('Poetry private operations use the requested project directory and token, not cached API routing', async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-tool-target-')); + scope = Uri.file(temp); + environment = { ...environment, sysPrefix: path.join(temp, 'selected') }; + await fs.ensureDir(environment.sysPrefix); + const poetry = new PoetryPackageManager(api, createMockLogOutputChannel(), {} as PoetryManager); + sinon.stub(poetryUtils, 'getPoetry').resolves(path.join(scope.fsPath, 'poetry')); + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + const show = sinon.stub(PoetryShowCommand.prototype, 'execute').resolves([]); + const target = sinon + .stub(poetryRunner, 'runPoetry') + .callsFake(async (args) => (args[0] === 'env' ? environment.sysPrefix : '[]')); + await poetry[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope); + assert.deepStrictEqual(target.firstCall.args[0], ['env', 'info', '--path']); + assert.strictEqual(target.firstCall.args[1], scope.fsPath); + assert.strictEqual(target.firstCall.args[3], source.token); + assert.strictEqual(target.firstCall.args[4], true); + assert.strictEqual(add.firstCall.args[0].cancellationToken, source.token); + assert.strictEqual(add.firstCall.args[0].toolExecution, true); + assert.strictEqual(Reflect.get(add.firstCall.thisValue, 'cwd'), scope.fsPath); + assert.deepStrictEqual(target.secondCall.args[0], ['install', '--no-root']); + assert.deepStrictEqual(target.thirdCall.args[0], [ + 'run', + 'pip', + 'list', + '--format=json', + '--disable-pip-version-check', + ]); + for (const call of target.getCalls()) { + assert.strictEqual(call.args[1], scope.fsPath); + assert.strictEqual(call.args[3], source.token); + assert.strictEqual(call.args[4], true); + } + assert.ok(show.notCalled); + assert.ok((api.getPythonProjects as sinon.SinonStub).notCalled); + assert.ok(progress.notCalled && errors.notCalled); + }); + + test('Poetry private inventory is strict installed JSON while public inventory retains poetry show', async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-tool-inventory-')); + scope = Uri.file(temp); + environment = { ...environment, sysPrefix: path.join(temp, 'selected') }; + await fs.ensureDir(environment.sysPrefix); + const poetry = new PoetryPackageManager(api, createMockLogOutputChannel(), {} as PoetryManager); + let output = '[{"name":"installed-only","version":"1.0.0"}]'; + const runner = sinon + .stub(poetryRunner, 'runPoetry') + .callsFake(async (args) => (args[0] === 'env' ? environment.sysPrefix : output)); + sinon.stub(poetryUtils, 'getPoetry').resolves(path.join(temp, 'poetry')); + const show = sinon + .stub(PoetryShowCommand.prototype, 'execute') + .resolves([{ name: 'locked-only', displayName: 'locked-only', version: '2.0.0' }]); + const installed = await poetry[pythonToolSupport].getPackages(environment, source.token, scope); + assert.deepStrictEqual( + installed.map((pkg) => pkg.name), + ['installed-only'], + ); + assert.ok(show.notCalled); + assert.ok(runner.secondCall.args[0].includes('--disable-pip-version-check')); + sinon.stub(workspaceFs, 'stat').resolves({ type: FileType.Directory, ctime: 0, mtime: 0, size: 0 }); + const scopedPoetry = poetry.createForProject({ name: 'project', uri: scope }); + const publicPackages = await scopedPoetry.getPackages(environment, { skipCache: true }); + assert.deepStrictEqual( + publicPackages?.map((pkg) => pkg.name), + ['locked-only'], + ); + assert.ok(show.calledOnce); + for (output of ['not json', '{}', '[{"name":"invalid"}]', '[{"name":"","version":"1.0"}]']) { + await assert.rejects(poetry[pythonToolSupport].getPackages(environment, source.token, scope)); + } + output = '[]'; + assert.deepStrictEqual(await poetry[pythonToolSupport].getPackages(environment, source.token, scope), []); + assert.ok(progress.notCalled && errors.notCalled); + }); + + test('Poetry private queries and mutations reject a different selected environment before running package commands', async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'poetry-tool-mismatch-')); + scope = Uri.file(temp); + environment = { ...environment, sysPrefix: path.join(temp, 'selected') }; + const poetry = new PoetryPackageManager(api, createMockLogOutputChannel(), {} as PoetryManager); + const wrongPrefix = path.join(scope.fsPath, 'different-environment'); + await Promise.all([fs.ensureDir(environment.sysPrefix), fs.ensureDir(wrongPrefix)]); + const target = sinon.stub(poetryRunner, 'runPoetry').resolves(wrongPrefix); + const add = sinon.stub(PoetryAddCommand.prototype, 'execute').resolves(); + const show = sinon.stub(PoetryShowCommand.prototype, 'execute').resolves([]); + const isMismatch = (error: unknown) => + error instanceof PythonToolError && error.code === 'ENVIRONMENT_MISMATCH'; + await assert.rejects( + poetry[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope), + isMismatch, + ); + await assert.rejects(poetry[pythonToolSupport].getPackages(environment, source.token, scope), isMismatch); + assert.strictEqual(target.callCount, 2); + assert.ok(add.notCalled && show.notCalled); + assert.ok(progress.notCalled && errors.notCalled); + }); + + test('Conda private readiness exposes real PET failure even when human initialization already completed', async () => { + const failure = new Error('PET discovery unavailable'); + const finder: Partial = { refresh: sinon.stub().rejects(failure) }; + const manager = new CondaEnvManager(finder as NativePythonFinder, api, createMockLogOutputChannel()); + progress.callsFake(async (_options, task) => task({ report: () => {} }, source.token)); + sinon.stub(condaUtils, 'getConda').resolves(path.join(scope.fsPath, 'conda.exe')); + sinon.stub(condaUtils, 'getCondaForGlobal').resolves(undefined); + const create = sinon + .stub(condaUtils, 'quickCreateConda') + .rejects(new Error('Must not create during failed discovery')); + await assert.doesNotReject(manager.initialize()); + await assert.rejects(manager[pythonToolSupport].initialize(), (error) => error === failure); + assert.ok(create.notCalled); + const human = await condaUtils.refreshCondaEnvs(false, finder as NativePythonFinder, api, manager.log, manager); + assert.deepStrictEqual(human, [], 'Human helper retains its best-effort default'); + await assert.doesNotReject(manager.refresh(undefined), 'Public refresh remains best-effort'); + await assert.rejects(manager[pythonToolSupport].initialize(), (error) => error === failure); + sinon.stub(condaUtils, 'refreshCondaEnvs').resolves([]); + await manager.refresh(undefined); + await assert.doesNotReject( + manager[pythonToolSupport].initialize(), + 'Successful refresh clears the private error', + ); + }); + + test('Conda strict discovery rejects malformed PET data instead of reporting empty discovery', async () => { + const finder: Partial = { refresh: sinon.stub().resolves(undefined) }; + const manager = new CondaEnvManager(finder as NativePythonFinder, api, createMockLogOutputChannel()); + await assert.rejects( + condaUtils.refreshCondaEnvs(false, finder as NativePythonFinder, api, manager.log, manager, true), + /invalid data/, + ); + }); + + test('pre-cancelled package requests do not start a command', async () => { + const pip = new PipPackageManager(api, createMockLogOutputChannel(), {} as VenvManager); + const run = sinon.stub(helpers, 'runPython').resolves(''); + source.cancel(); + await assert.rejects( + pip[pythonToolSupport].manage(environment, { install: ['requests'] }, source.token, scope), + CancellationError, + ); + assert.ok(run.notCalled); + }); + + for (const hasSelectedBase of [true, false]) { + test(`venv creation uses ${ + hasSelectedBase ? 'the selected base' : 'the newest discovered Python 3' + }`, async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'venv-tool-base-')); + scope = Uri.file(temp); + const selected = { ...environment, version: '3.11.9' }; + const newest = { ...environment, version: '3.14.3.final.0' }; + const created = { ...environment, sysPrefix: path.join(temp, '.venv') }; + await fs.ensureDir(created.sysPrefix); + const create = sinon.stub(venvUtils, 'quickCreateVenv').resolves({ environment: created }); + const globals = sinon.stub().resolves([{ ...environment, version: '2.7.18' }, selected, newest]); + const system = new SysPythonManager({} as NativePythonFinder, api, createMockLogOutputChannel()); + const manager = new VenvManager({} as NativePythonFinder, api, system, createMockLogOutputChannel()); + const result = await manager[pythonToolSupport].create!(scope, { + token: source.token, + baseEnvironment: hasSelectedBase ? selected : undefined, + getGlobalEnvironments: globals, + managePackages: sinon.stub().resolves(), + }); + assert.strictEqual(result, created); + assert.strictEqual(create.firstCall.args[4], hasSelectedBase ? selected : newest); + assert.strictEqual(globals.callCount, hasSelectedBase ? 0 : 1); + assert.ok(progress.notCalled && errors.notCalled); + }); + } + + for (const kind of [NativePythonEnvironmentKind.poetry, NativePythonEnvironmentKind.pipenv]) { + test(`${kind} tool configuration resolves an associated cache outside the workspace without public initialization`, async () => { + const project = path.join(scope.fsPath, 'service'); + const executable = path.join(scope.fsPath, 'cache', 'python'); + const data: NativeInfo[] = [ + { kind, project: scope.fsPath, prefix: path.join(scope.fsPath, 'parent-cache'), executable: 'parent' }, + { kind, project, prefix: path.dirname(executable), executable }, + { kind, project: path.join(scope.fsPath, 'other'), prefix: 'other', executable: 'other' }, + ]; + const finder = { refresh: sinon.stub().resolves(data) } as Partial as NativePythonFinder; + const manager = kind === NativePythonEnvironmentKind.poetry + ? new PoetryManager(finder, api) + : new PipenvManager(finder, api); + const initialize = sinon.stub(manager, 'initialize').throws(new Error('Public onboarding must not run')); + const resolve = sinon.stub(manager, 'resolve').resolves(environment); + const result = await manager[pythonToolSupport].resolveProject!(Uri.file(path.join(project, 'main.py'))); + assert.strictEqual(result, environment); + assert.strictEqual(resolve.firstCall.args[0].fsPath, Uri.file(executable).fsPath); + assert.strictEqual(resolve.firstCall.args[1], true); + assert.ok(initialize.notCalled); + }); + } + + test('cached project resolution rejects ambiguity rather than choosing an unrelated environment', async () => { + const kind = NativePythonEnvironmentKind.poetry; + const data: NativeInfo[] = ['first', 'second'].map((name) => ({ + kind, project: scope.fsPath, prefix: path.join(scope.fsPath, name), + executable: path.join(scope.fsPath, name, 'python'), + })); + const finder = { refresh: sinon.stub().resolves(data) } as Partial as NativePythonFinder; + const resolve = sinon.stub().resolves(environment); + await assert.rejects( + resolveToolProjectEnvironment(scope, kind, finder, resolve), + (error) => error instanceof PythonToolError && error.code === 'AMBIGUOUS_ENVIRONMENT', + ); + assert.ok(resolve.notCalled); + data.splice(0, data.length, { kind, project: path.join(scope.fsPath, 'sibling'), prefix: 'other', executable: 'other' }); + assert.strictEqual(await resolveToolProjectEnvironment(scope, kind, finder, resolve), undefined); + assert.ok(resolve.notCalled); + }); + + test('an unsupported selected base is not silently replaced with a different Python version', async () => { + const system = new SysPythonManager({} as NativePythonFinder, api, createMockLogOutputChannel()); + const manager = new VenvManager({} as NativePythonFinder, api, system, createMockLogOutputChannel()); + const create = sinon.stub(venvUtils, 'quickCreateVenv').resolves({ environment }); + const globals = sinon.stub().resolves([environment]); + await assert.rejects( + manager[pythonToolSupport].create!(scope, { + token: source.token, + baseEnvironment: { ...environment, version: '2.7.18' }, + getGlobalEnvironments: globals, + managePackages: sinon.stub().resolves(), + }), + (error) => error instanceof PythonToolError && error.code === 'MISSING_PYTHON', + ); + assert.ok(create.notCalled && globals.notCalled); + }); + + test('cold private discovery with no Python never starts installation onboarding', async () => { + progress.callsFake(async (_options, task) => task({ report: () => {} }, source.token)); + sinon.stub(cache, 'getSystemEnvForGlobal').resolves(undefined); + sinon.stub(cache, 'getSystemEnvForWorkspace').resolves(undefined); + sinon.stub(builtinUtils, 'refreshPythons').resolves([]); + sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([]); + sinon.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + const prompt = sinon.stub(uvInstaller, 'promptInstallPythonViaUv').throws(new Error('Unexpected onboarding')); + const versionPicker = sinon.stub(uvInstaller, 'selectPythonVersionToInstall').resolves(undefined); + const system = new SysPythonManager({} as NativePythonFinder, api, createMockLogOutputChannel()); + const venv = new VenvManager({} as NativePythonFinder, api, system, createMockLogOutputChannel()); + await venv[pythonToolSupport].initialize(); + assert.deepStrictEqual(await system[pythonToolSupport].getEnvironments('global'), []); + assert.ok(prompt.notCalled && versionPicker.notCalled && errors.notCalled); + await system.create('global'); + assert.ok(versionPicker.calledOnce, 'Explicit human install keeps its version picker'); + }); +}); diff --git a/src/test/internal/pythonTools.startup.unit.test.ts b/src/test/internal/pythonTools.startup.unit.test.ts new file mode 100644 index 000000000..fe2153d70 --- /dev/null +++ b/src/test/internal/pythonTools.startup.unit.test.ts @@ -0,0 +1,332 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { + CancellationTokenSource, + ConfigurationTarget, + Disposable, + EventEmitter, + Uri, + WorkspaceConfiguration, +} from 'vscode'; +import { PackageManager, PythonEnvironment } from '../../api'; +import { PYTHON_EXTENSION_ID, SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import * as persistentState from '../../common/persistentState'; +import { createDeferred, Deferred } from '../../common/utils/deferred'; +import * as frameUtils from '../../common/utils/frameUtils'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import { PythonEnvironmentApiImpl } from '../../extensionApi'; +import { PythonEnvironmentManagers } from '../../features/envManagers'; +import { applyInitialEnvironmentSelection } from '../../features/interpreterSelection'; +import { PythonProjectManagerImpl } from '../../features/projectManager'; +import { SYSTEM_WORKSPACE_KEY } from '../../managers/builtin/cache'; +import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; +import * as systemUtils from '../../managers/builtin/utils'; +import { VenvManager } from '../../managers/builtin/venvManager'; +import * as venvUtils from '../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; +import { createMockLogOutputChannel } from '../mocks/helper'; + +suite('Python tools stateless startup selection', () => { + let root: Uri; + let temp: string; + let source: CancellationTokenSource; + let stateValues: Map; + let workspaceSettings: Map; + let userDefaultManager: string | undefined; + let registry: PythonEnvironmentManagers; + let api: PythonEnvironmentApiImpl; + let projects: PythonProjectManagerImpl; + let base: PythonEnvironment; + let local: PythonEnvironment; + let nativeFinder: NativePythonFinder; + let disposables: Disposable[]; + let persistedFallback: Deferred; + let releaseFallback: Deferred; + let holdFirstFallback: boolean; + let create: sinon.SinonStub; + + async function makeEnvironment(prefix: string, managerId: string): Promise { + const executable = path.join(prefix, process.platform === 'win32' ? 'python.exe' : 'python'); + await fs.outputFile(executable, ''); + if (managerId === VENV_MANAGER_ID) { + await fs.outputFile(path.join(prefix, 'pyvenv.cfg'), 'version = 3.14.3\n'); + } + return { + envId: { id: prefix, managerId }, + name: path.basename(prefix), + displayName: prefix, + displayPath: executable, + environmentPath: Uri.file(executable), + sysPrefix: prefix, + execInfo: { run: { executable } }, + version: '3.14.3.final.0', + }; + } + + function createHostComponents(): void { + projects = new PythonProjectManagerImpl(); + projects.initialize(); + registry = new PythonEnvironmentManagers(projects); + type ApiArgs = ConstructorParameters; + api = new PythonEnvironmentApiImpl( + registry, + projects, + {} as ApiArgs[2], + {} as ApiArgs[3], + { onDidChangeEnvironmentVariables: new EventEmitter().event } as ApiArgs[4], + disposables, + ); + const system = new SysPythonManager(nativeFinder, api, createMockLogOutputChannel()); + const venv = new VenvManager(nativeFinder, api, system, createMockLogOutputChannel()); + const pip: PackageManager = { + name: 'pip', + manage: sinon.stub().resolves(), + refresh: sinon.stub().resolves(), + getPackages: sinon.stub().resolves([]), + }; + disposables.push( + api.registerEnvironmentManager(system, { extensionId: PYTHON_EXTENSION_ID }), + api.registerEnvironmentManager(venv, { extensionId: PYTHON_EXTENSION_ID }), + api.registerPackageManager(pip, { extensionId: PYTHON_EXTENSION_ID }), + ); + } + + async function disposeHostComponents(): Promise { + await new Promise((resolve) => setImmediate(resolve)); + disposables.splice(0).forEach((disposable) => disposable.dispose()); + registry.dispose(); + projects.dispose(); + } + + setup(async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'python-tools-startup-')); + root = Uri.file(path.join(temp, 'workspace with spaces')); + await fs.ensureDir(root.fsPath); + base = await makeEnvironment(path.join(temp, 'global python'), SYSTEM_MANAGER_ID); + local = { + ...base, + envId: { id: 'local', managerId: VENV_MANAGER_ID }, + environmentPath: Uri.file(path.join(root.fsPath, '.venv', 'python')), + sysPrefix: path.join(root.fsPath, '.venv'), + execInfo: { run: { executable: path.join(root.fsPath, '.venv', 'python') } }, + }; + source = new CancellationTokenSource(); + disposables = []; + stateValues = new Map(); + workspaceSettings = new Map(); + userDefaultManager = VENV_MANAGER_ID; + persistedFallback = createDeferred(); + releaseFallback = createDeferred(); + holdFirstFallback = true; + nativeFinder = {} as NativePythonFinder; + const state: persistentState.PersistentState = { + get: async (key: string, defaultValue?: T) => (stateValues.get(key) ?? defaultValue) as T | undefined, + set: async (key, value) => { + stateValues.set(key, value); + if (key === SYSTEM_WORKSPACE_KEY && holdFirstFallback) { + holdFirstFallback = false; + persistedFallback.resolve(); + await releaseFallback.promise; + } + }, + clear: async () => { + stateValues.clear(); + }, + }; + sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(state); + sinon.stub(persistentState, 'getGlobalPersistentState').resolves(state); + sinon.stub(frameUtils, 'getCallingExtension').returns(PYTHON_EXTENSION_ID); + const folder = { uri: root, name: 'workspace with spaces', index: 0 }; + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([folder]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(folder); + sinon.stub(workspaceApis, 'getWorkspaceFile').returns(undefined); + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); + for (const name of [ + 'onDidChangeConfiguration', + 'onDidChangeWorkspaceFolders', + 'onDidDeleteFiles', + 'onDidRenameFiles', + ] as const) { + sinon.stub(workspaceApis, name).returns(new Disposable(() => {})); + } + sinon.stub(workspaceApis, 'getConfiguration').callsFake((section) => { + const defaults = new Map([ + ['python-envs.defaultEnvManager', VENV_MANAGER_ID], + ['python-envs.defaultPackageManager', 'ms-python.python:pip'], + ['python-envs.pythonProjects', []], + ]); + const configuration: WorkspaceConfiguration = { + get: (key: string, fallback?: T) => + (workspaceSettings.get(`${section}.${key}`) ?? defaults.get(`${section}.${key}`) ?? fallback) as T, + inspect: (key: string) => ({ + key, + globalValue: (key === 'defaultEnvManager' ? userDefaultManager : undefined) as T | undefined, + workspaceValue: workspaceSettings.get(`${section}.${key}`) as T | undefined, + }), + has: (key) => defaults.has(`${section}.${key}`) || workspaceSettings.has(`${section}.${key}`), + update: async (key, value, target) => { + assert.notStrictEqual(target, ConfigurationTarget.Global); + workspaceSettings.set(`${section}.${key}`, value); + }, + }; + return configuration; + }); + sinon + .stub(windowApis, 'withProgress') + .callsFake(async (_options, task) => task({ report: () => {} }, source.token)); + sinon.stub(windowApis, 'showErrorMessage').throws(new Error('Unexpected question')); + sinon.stub(windowApis, 'showInformationMessage').throws(new Error('Unexpected question')); + sinon.stub(systemUtils, 'refreshPythons').resolves([base]); + sinon + .stub(venvUtils, 'findVirtualEnvironments') + .callsFake(async () => ((await fs.pathExists(local.execInfo.run.executable)) ? [local] : [])); + create = sinon.stub(venvUtils, 'quickCreateVenv').callsFake(async () => { + await fs.outputFile(local.execInfo.run.executable, ''); + await fs.outputFile(path.join(local.sysPrefix, 'pyvenv.cfg'), 'version = 3.14.3\n'); + return { environment: local }; + }); + createHostComponents(); + }); + + teardown(async () => { + releaseFallback.resolve(); + await disposeHostComponents(); + source.dispose(); + sinon.restore(); + await fs.remove(temp); + }); + + test('configure isolates a global selection while startup persistence is pending and preserves it on reload', async () => { + const startup = applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + await persistedFallback.promise; + try { + const configured = await api.__pythonTools.configureEnvironment( + { resourcePath: root.fsPath }, + source.token, + ); + assert.strictEqual(configured.status, 'success', JSON.stringify(configured)); + assert.strictEqual(configured.environment?.sysPrefix, local.sysPrefix); + assert.ok(create.calledOnce); + assert.notStrictEqual(workspaceSettings.get('python-envs.defaultEnvManager'), SYSTEM_MANAGER_ID); + } finally { + releaseFallback.resolve(); + await startup; + } + await disposeHostComponents(); + createHostComponents(); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + const afterReload = await api.__pythonTools.configureEnvironment({ resourcePath: root.fsPath }, source.token); + assert.strictEqual(afterReload.environment?.sysPrefix, local.sysPrefix); + assert.ok(create.calledOnce); + assert.strictEqual(stateValues.has('python-envs.selectionOrigins'), false); + }); + + test('a read-only refresh and reload do not prevent automatic project isolation', async () => { + const startup = applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + await persistedFallback.promise; + await registry.refreshEnvironment(root); + releaseFallback.resolve(); + await startup; + await disposeHostComponents(); + createHostComponents(); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + const result = await api.__pythonTools.configureEnvironment({ resourcePath: root.fsPath }, source.token); + assert.strictEqual(result.environment?.sysPrefix, local.sysPrefix); + assert.ok(create.calledOnce); + assert.strictEqual(stateValues.has('python-envs.selectionOrigins'), false); + }); + + test('a persisted global choice survives human startup but agent configure isolates it after reload', async () => { + holdFirstFallback = false; + stateValues.set(SYSTEM_WORKSPACE_KEY, { [root.fsPath]: base.environmentPath.fsPath }); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + assert.strictEqual(registry.getLastKnownEnvironment(root)?.sysPrefix, base.sysPrefix); + await disposeHostComponents(); + createHostComponents(); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + const configured = await api.__pythonTools.configureEnvironment({ resourcePath: root.fsPath }, source.token); + assert.strictEqual(configured.environment?.sysPrefix, local.sysPrefix); + assert.ok(create.calledOnce); + }); + + test('obsolete origin records are neither consulted nor updated', async () => { + holdFirstFallback = false; + const oldOrigins = { [root.toString()]: { path: base.environmentPath.fsPath, origin: 'explicit' } }; + stateValues.set('python-envs.selectionOrigins', oldOrigins); + stateValues.set(SYSTEM_WORKSPACE_KEY, { [root.fsPath]: base.environmentPath.fsPath }); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + const configured = await api.__pythonTools.configureEnvironment({}, source.token); + assert.strictEqual(configured.status, 'success', JSON.stringify(configured)); + assert.strictEqual(configured.environment?.sysPrefix, local.sysPrefix); + assert.ok(create.calledOnce); + assert.strictEqual(stateValues.get('python-envs.selectionOrigins'), oldOrigins); + }); + + test('agent selection persists across reload when a global defaultInterpreterPath is configured', async () => { + holdFirstFallback = false; + userDefaultManager = undefined; + workspaceSettings.set('python.defaultInterpreterPath', base.execInfo.run.executable); + sinon.stub(systemUtils, 'resolveSystemPythonEnvironmentPath').resolves(base); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + assert.strictEqual(registry.getLastKnownEnvironment(root)?.sysPrefix, base.sysPrefix); + const configured = await api.__pythonTools.configureEnvironment({}, source.token); + assert.strictEqual(configured.status, 'success', JSON.stringify(configured)); + assert.strictEqual(configured.environment?.sysPrefix, local.sysPrefix); + assert.strictEqual(workspaceSettings.get('python-envs.defaultEnvManager'), VENV_MANAGER_ID); + assert.strictEqual(workspaceSettings.get('python.defaultInterpreterPath'), base.execInfo.run.executable); + await disposeHostComponents(); + createHostComponents(); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + const selected = await api.__pythonTools.getEnvironment({}, source.token); + assert.strictEqual(selected.status, 'success', JSON.stringify(selected)); + assert.strictEqual(selected.environment?.sysPrefix, local.sysPrefix); + assert.ok(create.calledOnce); + }); + + test('a new nested directory persists its exact project and selection without replacing root defaults', async () => { + holdFirstFallback = false; + const nested = Uri.file(path.join(root.fsPath, 'New Service')); + await fs.ensureDir(nested.fsPath); + create.callsFake(async (...args: unknown[]) => { + const creationRoot = args[5] as Uri; + local = await makeEnvironment(path.join(creationRoot.fsPath, '.venv'), VENV_MANAGER_ID); + return { environment: local }; + }); + await applyInitialEnvironmentSelection(registry, projects, nativeFinder, api); + const configured = await api.__pythonTools.configureEnvironment({ resourcePath: nested.fsPath }, source.token); + assert.strictEqual(configured.status, 'success', JSON.stringify(configured)); + assert.strictEqual(configured.environment?.sysPrefix, path.join(nested.fsPath, '.venv')); + assert.deepStrictEqual(workspaceSettings.get('python-envs.pythonProjects'), [ + { + path: 'New Service', + envManager: VENV_MANAGER_ID, + packageManager: 'ms-python.python:pip', + }, + ]); + assert.strictEqual(workspaceSettings.get('python-envs.defaultEnvManager'), undefined); + await disposeHostComponents(); + createHostComponents(); + assert.ok(projects.getProjects().some((project) => project.uri.fsPath === nested.fsPath)); + const reloaded = await api.__pythonTools.getEnvironment({ resourcePath: nested.fsPath }, source.token); + assert.strictEqual(reloaded.environment?.sysPrefix, local.sysPrefix); + }); + + test('ordinary selection keeps the existing root-default behavior for an unconfigured nested project', async () => { + holdFirstFallback = false; + const nested = Uri.file(path.join(root.fsPath, 'Human Project')); + await fs.ensureDir(nested.fsPath); + await projects.add(projects.create('Human Project', nested), { persistSettings: false }); + await registry.setEnvironment(nested, base); + assert.strictEqual(workspaceSettings.get('python-envs.defaultEnvManager'), SYSTEM_MANAGER_ID); + assert.strictEqual(workspaceSettings.get('python-envs.pythonProjects'), undefined); + assert.strictEqual(registry.getLastKnownEnvironment(nested), base); + assert.strictEqual(stateValues.has('python-envs.selectionOrigins'), false); + }); +}); diff --git a/src/test/internal/pythonTools.unit.test.ts b/src/test/internal/pythonTools.unit.test.ts new file mode 100644 index 000000000..0293f2fb4 --- /dev/null +++ b/src/test/internal/pythonTools.unit.test.ts @@ -0,0 +1,987 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { + CancellationError, + CancellationToken, + CancellationTokenSource, + EventEmitter, + Uri, + WorkspaceConfiguration, +} from 'vscode'; +import { EnvironmentManager, Package, PackageManagementOptions, PythonEnvironment, PythonProject } from '../../api'; +import { + CONDA_MANAGER_ID, + INLINE_SCRIPT_MANAGER_ID, + PYENV_MANAGER_ID, + PYTHON_EXTENSION_ID, + SYSTEM_MANAGER_ID, + VENV_MANAGER_ID, +} from '../../common/constants'; +import { createDeferred } from '../../common/utils/deferred'; +import * as frameUtils from '../../common/utils/frameUtils'; +import { isSameOrParentPath, normalizePath } from '../../common/utils/pathUtils'; +import { ProcessTerminationError } from '../../common/utils/processRunner'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import { PythonEnvironmentManagers } from '../../features/envManagers'; +import { PythonProjectManager, PythonProjectSettings } from '../../features/projectManager'; +import * as settings from '../../features/settings/settingHelpers'; +import { PythonToolsApiImpl } from '../../internal/pythonTools'; +import { PythonToolResult } from '../../internal/pythonToolsApi'; +import { + PythonToolError, + pythonToolSupport, + ToolEnvironmentManager, + ToolPackageManager, +} from '../../internal/pythonToolSupport'; + +const POETRY_MANAGER_ID = `${PYTHON_EXTENSION_ID}:poetry`; +const PIPENV_MANAGER_ID = `${PYTHON_EXTENSION_ID}:pipenv`; + +suite('Internal Python tools', () => { + let temp: string; + let root: Uri; + let base: PythonEnvironment; + let environments: Map; + let projectList: PythonProject[]; + let projectSettings: PythonProjectSettings[]; + let explicitSettings: Map; + let projects: PythonProjectManager; + let managers: PythonEnvironmentManagers; + let api: PythonToolsApiImpl; + let source: CancellationTokenSource; + let venv: ToolEnvironmentManager; + let system: ToolEnvironmentManager; + let create: sinon.SinonStub; + let manage: sinon.SinonStub; + let listPackages: sinon.SinonStub; + let folders: sinon.SinonStub; + let progress: sinon.SinonStub; + + function environment(prefix: string, managerId = VENV_MANAGER_ID): PythonEnvironment { + const executable = path.join(prefix, process.platform === 'win32' ? 'python.exe' : 'python'); + return { + envId: { id: prefix, managerId }, + name: path.basename(prefix), + displayName: prefix, + displayPath: executable, + environmentPath: Uri.file(executable), + execInfo: { run: { executable } }, + sysPrefix: prefix, + version: '3.12.4', + }; + } + + async function addEnvironment(prefix: string, managerId = VENV_MANAGER_ID): Promise { + const env = environment(prefix, managerId); + await fs.outputFile(env.execInfo.run.executable, ''); + if ([VENV_MANAGER_ID, POETRY_MANAGER_ID, PIPENV_MANAGER_ID].includes(managerId)) { + await fs.outputFile(path.join(prefix, 'pyvenv.cfg'), 'version = 3.12.4\n'); + } + environments.set(normalizePath(prefix), env); + environments.set(normalizePath(env.execInfo.run.executable), env); + return env; + } + + function makeManager( + name: string, + fallback?: PythonEnvironment, + preferredPackageManagerId = `${PYTHON_EXTENSION_ID}:pip`, + ): ToolEnvironmentManager { + const selections = new Map(); + const manager: ToolEnvironmentManager = { + name, + preferredPackageManagerId, + get: sinon + .stub() + .callsFake(async (scope?: Uri) => + scope ? selections.get(normalizePath(scope.fsPath)) ?? fallback : fallback, + ), + set: sinon.stub().callsFake(async (scope: Uri | Uri[] | undefined, env?: PythonEnvironment) => { + for (const uri of Array.isArray(scope) ? scope : scope ? [scope] : []) { + if (env) { + selections.set(normalizePath(uri.fsPath), env); + } else { + selections.delete(normalizePath(uri.fsPath)); + } + } + }), + getEnvironments: sinon.stub().resolves(fallback ? [fallback] : []), + resolve: sinon.stub().callsFake(async (uri: Uri) => environments.get(normalizePath(uri.fsPath))), + refresh: sinon.stub().resolves(), + create: sinon.stub().throws(new Error('Interactive create must not be called')), + [pythonToolSupport]: { + initialize: sinon.stub().resolves(), + get: (scope) => manager.get(scope), + getEnvironments: (scope) => manager.getEnvironments(scope), + resolve: (scope) => manager.resolve(scope), + }, + }; + return manager; + } + + function registerAll(): void { + managers.registerEnvironmentManager(venv, { extensionId: PYTHON_EXTENSION_ID }); + managers.registerEnvironmentManager(system, { extensionId: PYTHON_EXTENSION_ID }); + const pip: ToolPackageManager = { + name: 'pip', + manage: sinon.stub().throws(new Error('Public package route must not be called')), + getPackages: sinon.stub().throws(new Error('Public package cache must not be called')), + refresh: sinon.stub().resolves(), + [pythonToolSupport]: { manage, getPackages: listPackages }, + }; + managers.registerPackageManager(pip, { extensionId: PYTHON_EXTENSION_ID }); + } + + setup(async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'python-tools-')); + root = Uri.file(path.join(temp, 'workspace')); + await fs.ensureDir(root.fsPath); + environments = new Map(); + base = await addEnvironment(path.join(temp, 'base'), SYSTEM_MANAGER_ID); + projectList = [{ name: 'workspace', uri: root }]; + projectSettings = []; + explicitSettings = new Map(); + source = new CancellationTokenSource(); + projects = { + initialize: () => {}, + dispose: () => {}, + create: (name, uri) => ({ name, uri }), + add: async (items) => { + projectList.push(...(Array.isArray(items) ? items : [items])); + }, + remove: () => {}, + getProjects: () => projectList, + get: (uri) => + [...projectList] + .sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length) + .find((project) => isSameOrParentPath(project.uri.fsPath, uri.fsPath)), + onDidChangeProjects: new EventEmitter().event, + }; + sinon.stub(frameUtils, 'getCallingExtension').returns(PYTHON_EXTENSION_ID); + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); + folders = sinon + .stub(workspaceApis, 'getWorkspaceFolders') + .returns([{ uri: root, name: 'workspace', index: 0 }]); + sinon + .stub(workspaceApis, 'getWorkspaceFolder') + .callsFake((uri) => + folders().find((folder: { uri: Uri }) => isSameOrParentPath(folder.uri.fsPath, uri.fsPath)), + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake( + (section) => + ({ + get: (key: string, fallback?: T): T => { + const value = explicitSettings.get(`${section}.${key}`); + return (value ?? + (key === 'defaultEnvManager' + ? VENV_MANAGER_ID + : key === 'pythonProjects' + ? projectSettings + : key === 'defaultPackageManager' + ? `${PYTHON_EXTENSION_ID}:pip` + : fallback)) as T; + }, + inspect: (key: string) => ({ key, globalValue: explicitSettings.get(`${section}.${key}`) }), + has: () => true, + update: async () => {}, + } as WorkspaceConfiguration), + ); + sinon.stub(settings, 'setAllManagerSettings').callsFake(async (edits) => { + for (const edit of edits) { + if (edit.project) { + const relative = path.relative(root.fsPath, edit.project.uri.fsPath); + projectSettings = projectSettings.filter((setting) => setting.path !== relative); + projectSettings.push({ + path: relative, + envManager: edit.envManager, + packageManager: edit.packageManager, + }); + } + } + }); + progress = sinon.stub(windowApis, 'withProgress').throws(new Error('Tool requested UI')); + sinon.stub(windowApis, 'showQuickPick').throws(new Error('Tool requested a picker')); + sinon.stub(windowApis, 'showErrorMessage').throws(new Error('Tool requested a notification')); + venv = makeManager('venv', base); + system = makeManager('system', base); + create = sinon.stub().callsFake(async (scope: Uri) => addEnvironment(path.join(scope.fsPath, '.venv'))); + venv[pythonToolSupport].create = create; + manage = sinon.stub().resolves(); + listPackages = sinon.stub().resolves([]); + managers = new PythonEnvironmentManagers(projects); + registerAll(); + api = new PythonToolsApiImpl(managers, projects); + }); + + teardown(async () => { + source.dispose(); + managers.dispose(); + sinon.restore(); + await fs.remove(temp); + }); + + function invoke(method: keyof Omit, request: unknown): Promise { + return Reflect.apply(api[method], api, [request, source.token]); + } + + test('creates a local environment rather than using automatic global fallback, then reuses it', async () => { + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status, 'success'); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.strictEqual(result.environment?.sysPrefix, path.join(root.fsPath, '.venv')); + assert.strictEqual(result.resourcePath, root.fsPath); + const second = await api.configureEnvironment({}, source.token); + assert.strictEqual(second.status === 'success' && second.created, false); + assert.strictEqual(second.environment, result.environment); + assert.ok(create.calledOnce); + assert.ok(progress.notCalled); + }); + + test('creates an isolated environment after a global startup selection and reload', async () => { + await managers.setEnvironment(root, base, false); + managers.dispose(); + managers = new PythonEnvironmentManagers(projects); + registerAll(); + await managers.setEnvironment(root, base, false); + api = new PythonToolsApiImpl(managers, projects); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, base); + }); + + test('isolates a pre-existing global selection without requiring selection history', async () => { + await system.set(root, base); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.strictEqual(result.environment?.sysPrefix, path.join(root.fsPath, '.venv')); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, base); + }); + + test('isolates a manually selected global interpreter and installs only into the new environment', async () => { + await managers.setEnvironment(root, base); + const blocked = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(blocked.status === 'error' && blocked.code, 'ENVIRONMENT_NOT_ISOLATED'); + assert.strictEqual(blocked.environment, base); + assert.ok(manage.notCalled); + assert.strictEqual(managers.getLastKnownEnvironment(root), base); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, base); + const install = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(install.status, 'success'); + assert.strictEqual(manage.firstCall.args[0], result.environment); + assert.ok(manage.calledOnce); + assert.ok(create.calledOnce); + }); + + test('public global selection and read-only queries do not create or change environments', async () => { + await managers.setEnvironment(root, base); + const result = await api.getEnvironment({ includePackages: true }, source.token); + assert.strictEqual(result.status, 'success'); + assert.strictEqual(result.environment, base); + assert.strictEqual(managers.getLastKnownEnvironment(root), base); + assert.ok(create.notCalled); + assert.ok(manage.notCalled); + }); + + for (const [name, managerId] of [ + ['venv', VENV_MANAGER_ID], + ['poetry', POETRY_MANAGER_ID], + ['pipenv', PIPENV_MANAGER_ID], + ['pyenv', PYENV_MANAGER_ID], + ]) { + test(`reuses an isolated ${name} environment outside the project without history`, async () => { + const isolated = await addEnvironment(path.join(temp, `${name}-cache`), managerId); + await fs.outputFile(path.join(isolated.sysPrefix, 'pyvenv.cfg'), 'version = 3.12.4\n'); + const manager = name === 'venv' ? venv : makeManager(name); + if (name !== 'venv') { + managers.registerEnvironmentManager(manager, { extensionId: PYTHON_EXTENSION_ID }); + } + await managers.setEnvironment(root, isolated); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, false); + assert.strictEqual(result.environment, isolated); + const install = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(install.status, 'success'); + assert.strictEqual(manage.firstCall.args[0], isolated); + assert.ok(create.notCalled); + }); + } + + test('uses a selected Pyenv base to create a project venv instead of invoking Pyenv creation', async () => { + const selected = await addEnvironment(path.join(temp, 'pyenv-python'), PYENV_MANAGER_ID); + const pyenv = makeManager('pyenv', selected); + managers.registerEnvironmentManager(pyenv, { extensionId: PYTHON_EXTENSION_ID }); + await managers.setEnvironment(root, selected); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.strictEqual(result.environment?.envId.managerId, VENV_MANAGER_ID); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, selected); + assert.ok((pyenv.create as sinon.SinonStub).notCalled); + }); + + test('configuration selects a discovered cached project environment without changing read-only lookup', async () => { + const cached = await addEnvironment(path.join(temp, 'poetry-cache'), POETRY_MANAGER_ID); + const poetry = makeManager('poetry'); + const resolveProject = sinon.stub().resolves(cached); + poetry[pythonToolSupport].resolveProject = resolveProject; + managers.registerEnvironmentManager(poetry, { extensionId: PYTHON_EXTENSION_ID }); + explicitSettings.set('python-envs.defaultEnvManager', POETRY_MANAGER_ID); + const query = await api.getEnvironment({}, source.token); + assert.strictEqual(query.status === 'error' && query.code, 'ENVIRONMENT_NOT_FOUND'); + assert.ok(resolveProject.notCalled); + const configured = await api.configureEnvironment({}, source.token); + assert.strictEqual(configured.status === 'success' && configured.created, false); + assert.strictEqual(configured.environment, cached); + assert.strictEqual(resolveProject.firstCall.args[0].fsPath, root.fsPath); + assert.strictEqual((await api.getEnvironment({}, source.token)).environment, cached); + assert.ok(create.notCalled && (poetry.create as sinon.SinonStub).notCalled); + }); + + test('does not treat a global interpreter located inside the workspace as isolated', async () => { + const portable = await addEnvironment(path.join(root.fsPath, 'portable-python'), SYSTEM_MANAGER_ID); + await managers.setEnvironment(root, portable); + const install = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(install.status === 'error' && install.code, 'ENVIRONMENT_NOT_ISOLATED'); + const configured = await api.configureEnvironment({}, source.token); + assert.strictEqual(configured.status === 'success' && configured.created, true); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, portable); + assert.ok(manage.notCalled); + }); + + test('rejects creation that returns a global interpreter instead of an isolated environment', async () => { + create.resolves(base); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'error' && result.code, 'INVALID_ENVIRONMENT'); + assert.strictEqual(result.environment, base); + assert.ok((system.set as sinon.SinonStub).notCalled); + }); + + test('reusing an existing environment directory uses only the private resolver', async () => { + const local = await addEnvironment(path.join(root.fsPath, '.venv')); + venv[pythonToolSupport].resolve = sinon.stub().resolves(local); + (venv.resolve as sinon.SinonStub).rejects(new Error('Public resolver must not be called')); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status, 'success', JSON.stringify(result)); + assert.strictEqual(result.environment, local); + assert.ok(create.notCalled); + }); + + test('honors an explicit interpreter file URI and keeps the public setter flat', async () => { + const alternate = await addEnvironment(path.join(temp, 'alternate'), SYSTEM_MANAGER_ID); + const result = await api.configureEnvironment( + { resourcePath: root.toString(), pythonPath: alternate.environmentPath.toString() }, + source.token, + ); + assert.strictEqual(result.environment, alternate); + assert.strictEqual((await api.getEnvironment({}, source.token)).environment, alternate); + assert.ok(create.notCalled); + }); + + test('explicit global pythonPath is exact selection, not permission for global package writes', async () => { + const selected = await api.configureEnvironment({ pythonPath: base.execInfo.run.executable }, source.token); + assert.strictEqual(selected.status === 'success' && selected.created, false); + assert.strictEqual(selected.environment, base); + assert.ok(create.notCalled); + const install = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(install.status === 'error' && install.code, 'ENVIRONMENT_NOT_ISOLATED'); + assert.ok(manage.notCalled); + const configured = await api.configureEnvironment({}, source.token); + assert.strictEqual(configured.status === 'success' && configured.created, true); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, base); + }); + + test('an explicit Python path can switch away from an unavailable old manager', async () => { + explicitSettings.set('python-envs.defaultEnvManager', 'missing.extension:manager'); + const alternate = await addEnvironment(path.join(temp, 'alternate'), SYSTEM_MANAGER_ID); + const result = await api.configureEnvironment({ pythonPath: alternate.execInfo.run.executable }, source.token); + assert.strictEqual(result.status, 'success'); + assert.strictEqual(result.environment, alternate); + }); + + test('serializes project-root and file aliases by their effective scope and rechecks after creation', async () => { + const script = Uri.file(path.join(root.fsPath, 'main.py')); + await fs.outputFile(script.fsPath, ''); + const started = createDeferred(); + const release = createDeferred(); + create.callsFake(async (scope: Uri) => { + started.resolve(); + await release.promise; + return addEnvironment(path.join(scope.fsPath, '.venv')); + }); + const first = api.configureEnvironment({ resourcePath: root.fsPath }, source.token); + await started.promise; + const second = api.configureEnvironment({ resourcePath: script.fsPath }, source.token); + release.resolve(); + const results = await Promise.all([first, second]); + assert.ok(results.every((result) => result.status === 'success')); + assert.strictEqual(results[0].environment, results[1].environment); + assert.ok(create.calledOnce); + assert.strictEqual(results[1].status === 'success' && results[1].created, false); + }); + + test('queued operations wait beyond the discovery timeout and a cancelled waiter cannot release the next reader early', async () => { + const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const started = createDeferred(); + const release = createDeferred(); + create.callsFake(async (scope: Uri) => { + started.resolve(); + await release.promise; + return addEnvironment(path.join(scope.fsPath, '.venv')); + }); + const first = api.configureEnvironment({ resourcePath: root.fsPath }, source.token); + await started.promise; + const waiterSource = new CancellationTokenSource(); + try { + const entered = createDeferred(); + const initialize = venv[pythonToolSupport].initialize as sinon.SinonStub; + initialize.onCall(initialize.callCount).callsFake(async () => entered.resolve()); + let completed = false; + const queued = api.configureEnvironment({ resourcePath: root.fsPath }, waiterSource.token).then( + (result) => { + completed = true; + return { result }; + }, + (error: unknown) => { + completed = true; + return { error }; + }, + ); + await entered.promise; + await clock.tickAsync(31_000); + assert.strictEqual(completed, false, 'An owned operation is not a discovery timeout'); + waiterSource.cancel(); + const cancelled = await queued; + assert.ok('error' in cancelled && cancelled.error instanceof CancellationError); + + const readEntered = createDeferred(); + initialize.onCall(initialize.callCount).callsFake(async () => readEntered.resolve()); + let readCompleted = false; + const read = api.getEnvironment({ resourcePath: root.fsPath }, source.token).then((result) => { + readCompleted = true; + return result; + }); + await readEntered.promise; + await clock.tickAsync(0); + assert.strictEqual(readCompleted, false); + release.resolve(); + const configured = await first; + const result = await read; + assert.strictEqual(result.status, 'success'); + assert.strictEqual(result.environment, configured.environment); + assert.ok(create.calledOnce); + } finally { + release.resolve(); + await first; + waiterSource.dispose(); + } + }); + + test('package reads wait for the preceding install, without blocking another workspace', async () => { + await api.configureEnvironment({ resourcePath: root.fsPath }, source.token); + const other = Uri.file(path.join(temp, 'other-workspace')); + await fs.ensureDir(other.fsPath); + projectList.push({ name: 'other', uri: other }); + folders.returns([ + { uri: root, name: 'workspace', index: 0 }, + { uri: other, name: 'other', index: 1 }, + ]); + const started = createDeferred(); + const release = createDeferred(); + manage.callsFake(async () => { + started.resolve(); + await release.promise; + listPackages.resolves([{ name: 'installed', version: '1.0' }]); + }); + const install = api.installPackages({ resourcePath: root.fsPath, packages: ['installed'] }, source.token); + await started.promise; + const read = api.getEnvironment({ resourcePath: root.fsPath, includePackages: true }, source.token); + try { + const independent = await api.getEnvironment({ resourcePath: other.fsPath }, source.token); + assert.strictEqual(independent.status, 'success'); + assert.ok(listPackages.notCalled); + release.resolve(); + assert.strictEqual((await install).status, 'success'); + const result = await read; + assert.deepStrictEqual(result.status === 'success' && result.packages, [ + { name: 'installed', version: '1.0' }, + ]); + } finally { + release.resolve(); + await Promise.all([install, read]); + } + }); + + test('uses each project-scoped Poetry manager when two projects share an environment', async () => { + const second = Uri.file(path.join(root.fsPath, 'second')); + await fs.ensureDir(second.fsPath); + projectList.push({ name: 'second', uri: second }); + const shared = await addEnvironment(path.join(temp, 'shared-poetry'), POETRY_MANAGER_ID); + const poetry = makeManager('poetry', undefined, POETRY_MANAGER_ID); + managers.registerEnvironmentManager(poetry, { extensionId: PYTHON_EXTENSION_ID }); + await managers.setEnvironment(root, shared, false); + await managers.setEnvironment(second, shared, false); + projectSettings = [root, second].map((uri) => ({ + path: path.relative(root.fsPath, uri.fsPath), + envManager: POETRY_MANAGER_ID, + packageManager: POETRY_MANAGER_ID, + })); + + const calls: { operation: 'get' | 'manage'; project: string; scope: string }[] = []; + const poetryPackages: ToolPackageManager = { + name: 'poetry', + manage: sinon.stub().throws(new Error('Unscoped package route must not be called')), + getPackages: sinon.stub().throws(new Error('Unscoped package cache must not be called')), + refresh: sinon.stub().resolves(), + createForProject: (project) => ({ + name: 'poetry', + manage: sinon.stub().throws(new Error('Public package route must not be called')), + getPackages: sinon.stub().throws(new Error('Public package cache must not be called')), + refresh: sinon.stub().resolves(), + [pythonToolSupport]: { + getPackages: async ( + _environment: PythonEnvironment, + _token: CancellationToken, + scope: Uri, + ) => { + calls.push({ operation: 'get', project: project.uri.fsPath, scope: scope.fsPath }); + return [{ name: `${project.name}-package`, version: '1.0' }]; + }, + manage: async ( + _environment: PythonEnvironment, + _options: PackageManagementOptions, + _token: CancellationToken, + scope: Uri, + ) => { + calls.push({ operation: 'manage', project: project.uri.fsPath, scope: scope.fsPath }); + }, + }, + }), + [pythonToolSupport]: { + getPackages: sinon.stub().throws(new Error('Unscoped package tool must not be called')), + manage: sinon.stub().throws(new Error('Unscoped package tool must not be called')), + }, + }; + managers.registerPackageManager(poetryPackages, { extensionId: PYTHON_EXTENSION_ID }); + + const firstQuery = await api.getEnvironment( + { resourcePath: root.fsPath, includePackages: true }, + source.token, + ); + const firstInstall = await api.installPackages( + { resourcePath: root.fsPath, packages: ['first-package'] }, + source.token, + ); + const secondQuery = await api.getEnvironment( + { resourcePath: second.fsPath, includePackages: true }, + source.token, + ); + const secondInstall = await api.installPackages( + { resourcePath: second.fsPath, packages: ['second-package'] }, + source.token, + ); + + assert.deepStrictEqual(firstQuery.status === 'success' && firstQuery.packages, [ + { name: 'workspace-package', version: '1.0' }, + ]); + assert.strictEqual(firstInstall.status, 'success'); + assert.deepStrictEqual(secondQuery.status === 'success' && secondQuery.packages, [ + { name: 'second-package', version: '1.0' }, + ]); + assert.strictEqual(secondInstall.status, 'success'); + assert.deepStrictEqual(calls, [ + { operation: 'get', project: root.fsPath, scope: root.fsPath }, + { operation: 'manage', project: root.fsPath, scope: root.fsPath }, + { operation: 'get', project: second.fsPath, scope: second.fsPath }, + { operation: 'manage', project: second.fsPath, scope: second.fsPath }, + ]); + }); + + test('reports completed selection as success when cancellation arrives after persistence', async () => { + const persist = managers.setEnvironmentForTools.bind(managers); + sinon.stub(managers, 'setEnvironmentForTools').callsFake(async (scope, environment, token) => { + await persist(scope, environment, token); + source.cancel(); + }); + const result = await api.configureEnvironment({ resourcePath: root.fsPath }, source.token); + assert.strictEqual(result.status, 'success'); + assert.strictEqual(managers.getLastKnownEnvironment(root), result.environment); + assert.ok(projectSettings.length > 0, 'The completed settings write is not presented as cancelled'); + }); + + test('reports a completed package operation as success when cancellation arrives on return', async () => { + await api.configureEnvironment({ resourcePath: root.fsPath }, source.token); + let installed = false; + manage.callsFake(async () => { + installed = true; + source.cancel(); + }); + const result = await api.installPackages({ resourcePath: root.fsPath, packages: ['installed'] }, source.token); + assert.ok(installed); + assert.strictEqual(result.status, 'success'); + }); + + test('explicit interpreter selection rechecks scope if inline routing changes during preflight', async () => { + const script = Uri.file(path.join(root.fsPath, 'script.py')); + await fs.outputFile(script.fsPath, ''); + const inline = makeManager('inline-script'); + managers.registerEnvironmentManager(inline, { extensionId: PYTHON_EXTENSION_ID }); + const persist = sinon.spy(managers, 'setEnvironmentForTools'); + system[pythonToolSupport].describe = async (environment) => { + projectList.push({ uri: script, name: 'script' }); + projectSettings.push({ path: 'script.py', envManager: INLINE_SCRIPT_MANAGER_ID }); + return environment; + }; + const result = await api.configureEnvironment( + { resourcePath: script.fsPath, pythonPath: base.execInfo.run.executable }, + source.token, + ); + assert.strictEqual(result.status === 'error' && result.code, 'RESOURCE_CHANGED'); + assert.ok(persist.notCalled); + assert.ok(create.notCalled); + }); + + test('preserves a created environment when persisting its selection fails', async () => { + sinon.stub(managers, 'setEnvironmentForTools').rejects(new Error('Settings write failed')); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'error' && result.code, 'CONFIGURATION_FAILED'); + assert.strictEqual(result.environment?.sysPrefix, path.join(root.fsPath, '.venv')); + assert.ok(result.environment && (await fs.pathExists(result.environment.execInfo.run.executable))); + }); + + test('uses the configured default interpreter as the creation base instead of a cached global', async () => { + const alternate = await addEnvironment(path.join(temp, 'alternate'), SYSTEM_MANAGER_ID); + explicitSettings.set('python.defaultInterpreterPath', alternate.execInfo.run.executable); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, alternate); + }); + + test('routes a registered nested Python file to its exact scope but creates in its directory', async () => { + const script = Uri.file(path.join(root.fsPath, 'nested', 'script.py')); + await fs.outputFile(script.fsPath, ''); + projectList.push({ uri: script, name: 'script' }); + const result = await api.configureEnvironment({ resourcePath: script.fsPath }, source.token); + assert.strictEqual(result.status, 'success'); + assert.strictEqual(result.resourcePath, script.fsPath); + assert.strictEqual(create.firstCall.args[0].fsPath, path.dirname(script.fsPath)); + assert.strictEqual((venv.set as sinon.SinonStub).firstCall.args[0].fsPath, script.fsPath); + }); + + test('keeps explicit nested directories separate from their containing project', async () => { + const nested = Uri.file(path.join(root.fsPath, 'nested')); + await fs.ensureDir(nested.fsPath); + const result = await api.configureEnvironment({ resourcePath: nested.fsPath }, source.token); + assert.strictEqual(result.resourcePath, nested.fsPath); + assert.strictEqual(result.environment?.sysPrefix, path.join(nested.fsPath, '.venv')); + assert.strictEqual(projectList.length, 2); + }); + + test('supports inline-script creation by explicit capability without quickCreateConfig', async () => { + const script = Uri.file(path.join(root.fsPath, 'script.py')); + await fs.outputFile(script.fsPath, ''); + projectList.push({ uri: script, name: 'script' }); + projectSettings.push({ path: 'script.py', envManager: INLINE_SCRIPT_MANAGER_ID }); + const inline = makeManager('inline-script'); + const created = await addEnvironment(path.join(temp, 'inline-cache'), INLINE_SCRIPT_MANAGER_ID); + const createInline = sinon.stub().resolves(created); + inline[pythonToolSupport].create = createInline; + managers.registerEnvironmentManager(inline, { extensionId: PYTHON_EXTENSION_ID }); + sinon.stub(managers, 'setEnvironmentForTools').resolves(); + const result = await api.configureEnvironment({ resourcePath: script.fsPath }, source.token); + assert.strictEqual(result.environment, created); + assert.strictEqual(createInline.firstCall.args[0].fsPath, script.fsPath); + }); + + test('configuration can set up a fresh inline script without changing ordinary pre-setup routing', async () => { + const script = Uri.file(path.join(root.fsPath, 'script.py')); + await fs.outputFile(script.fsPath, '# /// script\n# dependencies = []\n# ///\n'); + const inline = makeManager('inline-script'); + const created = await addEnvironment(path.join(temp, 'inline-cache'), INLINE_SCRIPT_MANAGER_ID); + const canConfigure = sinon.stub().resolves(true); + const createInline = sinon.stub().resolves(created); + inline[pythonToolSupport].canConfigure = canConfigure; + inline[pythonToolSupport].create = createInline; + managers.registerEnvironmentManager(inline, { extensionId: PYTHON_EXTENSION_ID }); + const persist = sinon.stub(managers, 'setEnvironmentForTools').resolves(); + assert.strictEqual(managers.getEnvironmentManager(script)?.id, VENV_MANAGER_ID); + assert.strictEqual((await api.getEnvironment({ resourcePath: script.fsPath }, source.token)).environment, base); + assert.ok(canConfigure.notCalled); + const configured = await api.configureEnvironment({ resourcePath: script.fsPath }, source.token); + assert.strictEqual(configured.status, 'success', JSON.stringify(configured)); + assert.strictEqual(configured.environment, created); + assert.strictEqual(configured.resourcePath, script.fsPath); + assert.strictEqual(persist.firstCall.args[0].fsPath, script.fsPath); + assert.strictEqual(createInline.firstCall.args[0].fsPath, script.fsPath); + assert.ok(create.notCalled); + assert.strictEqual(projectList.length, 1); + }); + + test('directory configuration never treats an enabled inline manager as its creator', async () => { + const inline = makeManager('inline-script'); + const canConfigure = sinon.stub().resolves(true); + inline[pythonToolSupport].canConfigure = canConfigure; + managers.registerEnvironmentManager(inline, { extensionId: PYTHON_EXTENSION_ID }); + const configured = await api.configureEnvironment({}, source.token); + assert.strictEqual(configured.status === 'success' && configured.created, true); + assert.ok(create.calledOnce); + assert.ok(canConfigure.notCalled); + }); + + test('inline setup can install owned dependencies but direct tool package mutation is rejected', async () => { + const script = Uri.file(path.join(root.fsPath, 'script.py')); + await fs.outputFile(script.fsPath, ''); + projectList.push({ uri: script, name: 'script' }); + projectSettings.push({ path: 'script.py', envManager: INLINE_SCRIPT_MANAGER_ID }); + const inline = makeManager('inline-script'); + const created = await addEnvironment(path.join(temp, 'inline-cache'), INLINE_SCRIPT_MANAGER_ID); + inline[pythonToolSupport].create = async (_scope, operation) => { + await operation.managePackages(created, { install: ['requests'] }); + (inline.get as sinon.SinonStub).resolves(created); + return created; + }; + managers.registerEnvironmentManager(inline, { extensionId: PYTHON_EXTENSION_ID }); + sinon.stub(managers, 'setEnvironmentForTools').resolves(); + const configured = await api.configureEnvironment({ resourcePath: script.fsPath }, source.token); + assert.strictEqual(configured.status, 'success', JSON.stringify(configured)); + assert.ok(manage.calledOnce); + const install = await api.installPackages({ resourcePath: script.fsPath, packages: ['other'] }, source.token); + assert.strictEqual(install.status === 'error' && install.code, 'IMMUTABLE_ENVIRONMENT'); + assert.strictEqual(install.environment, created); + assert.ok(manage.calledOnce, 'No second package mutation may enter the shared cache'); + }); + + test('rejects unsupported creation instead of calling an interactive manager create', async () => { + const poetry = makeManager('poetry'); + managers.registerEnvironmentManager(poetry, { extensionId: PYTHON_EXTENSION_ID }); + explicitSettings.set('python-envs.defaultEnvManager', POETRY_MANAGER_ID); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'error' && result.code, 'UNSUPPORTED_CREATION'); + assert.ok((poetry.create as sinon.SinonStub).notCalled); + assert.ok(create.notCalled); + }); + + test('a System manager setting without a selection still routes automatic creation through venv', async () => { + explicitSettings.set('python-envs.defaultEnvManager', SYSTEM_MANAGER_ID); + (system.get as sinon.SinonStub).resolves(undefined); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, true); + assert.ok(create.calledOnce); + assert.strictEqual(create.firstCall.args[1].baseEnvironment, undefined); + assert.ok((system.create as sinon.SinonStub).notCalled); + }); + + test('rejects contributed managers that have not opted into the internal capability', async () => { + const contributed: EnvironmentManager = { ...makeManager('custom') }; + Reflect.deleteProperty(contributed, pythonToolSupport); + managers.registerEnvironmentManager(contributed, { extensionId: PYTHON_EXTENSION_ID }); + explicitSettings.set('python-envs.defaultEnvManager', `${PYTHON_EXTENSION_ID}:custom`); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'error' && result.code, 'UNSUPPORTED_MANAGER'); + assert.ok((contributed.get as sinon.SinonStub).notCalled); + }); + + test('uses Conda capability instead of replacing the configured manager with venv', async () => { + const conda = makeManager('conda', base); + const createConda = sinon + .stub() + .callsFake(async (scope: Uri) => addEnvironment(path.join(scope.fsPath, '.conda'), CONDA_MANAGER_ID)); + conda[pythonToolSupport].create = createConda; + managers.registerEnvironmentManager(conda, { extensionId: PYTHON_EXTENSION_ID }); + explicitSettings.set('python-envs.defaultEnvManager', CONDA_MANAGER_ID); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.environment?.envId.managerId, CONDA_MANAGER_ID); + assert.ok(createConda.calledOnce); + assert.ok(create.notCalled); + }); + + for (const manual of [false, true]) { + test(`isolates ${ + manual ? 'manually selected' : 'automatically selected' + } Conda base before package writes`, async () => { + const condaBase = { + ...(await addEnvironment(path.join(temp, 'conda-base'), CONDA_MANAGER_ID)), + name: 'base', + }; + const conda = makeManager('conda', condaBase); + const createConda = sinon + .stub() + .callsFake(async (scope: Uri) => addEnvironment(path.join(scope.fsPath, '.conda'), CONDA_MANAGER_ID)); + conda[pythonToolSupport].create = createConda; + managers.registerEnvironmentManager(conda, { extensionId: PYTHON_EXTENSION_ID }); + explicitSettings.set('python-envs.defaultEnvManager', CONDA_MANAGER_ID); + if (manual) { + await managers.setEnvironment(root, condaBase); + } + const install = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(install.status === 'error' && install.code, 'ENVIRONMENT_NOT_ISOLATED'); + assert.ok(manage.notCalled); + const configure = await api.configureEnvironment({}, source.token); + assert.strictEqual(configure.status, 'success', JSON.stringify(configure)); + assert.strictEqual(configure.environment?.sysPrefix, path.join(root.fsPath, '.conda')); + assert.ok(createConda.calledOnce); + assert.strictEqual(createConda.firstCall.args[1].baseEnvironment, condaBase); + assert.ok(create.notCalled); + }); + } + + for (const name of ['named-environment', 'base']) { + test(`reuses an existing isolated Conda prefix named ${name}`, async () => { + const isolated = { + ...(await addEnvironment(path.join(temp, 'environments', name), CONDA_MANAGER_ID)), + group: 'Prefix', + }; + const conda = makeManager('conda', isolated); + managers.registerEnvironmentManager(conda, { extensionId: PYTHON_EXTENSION_ID }); + explicitSettings.set('python-envs.defaultEnvManager', CONDA_MANAGER_ID); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'success' && result.created, false); + assert.strictEqual(result.environment, isolated); + assert.strictEqual((await api.installPackages({ packages: ['requests'] }, source.token)).status, 'success'); + assert.strictEqual(manage.firstCall.args[0], isolated); + assert.ok(create.notCalled); + }); + } + + test('package queries return an explicit empty array and preserve query failures with the environment', async () => { + const empty = await api.getEnvironment({ includePackages: true }, source.token); + assert.deepStrictEqual(empty.status === 'success' && empty.packages, []); + listPackages.rejects(new Error('pip list failed')); + const failed = await api.getEnvironment({ includePackages: true }, source.token); + assert.strictEqual(failed.status === 'error' && failed.code, 'PACKAGE_QUERY_FAILED'); + assert.strictEqual(failed.environment, base); + }); + + test('package queries return string version DTOs', async () => { + listPackages.resolves([{ name: 'requests', version: '2.32.0' } as Package]); + const result = await api.getEnvironment({ includePackages: true }, source.token); + assert.deepStrictEqual(result.status === 'success' && result.packages, [ + { name: 'requests', version: '2.32.0' }, + ]); + }); + + test('requires configure before installing into automatic global fallback and forwards the real token', async () => { + const initial = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(initial.status === 'error' && initial.code, 'ENVIRONMENT_NOT_ISOLATED'); + assert.ok(manage.notCalled); + await api.configureEnvironment({}, source.token); + const result = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(result.status, 'success'); + assert.strictEqual(manage.firstCall.args[1].runHeadless, true); + assert.strictEqual(manage.firstCall.args[2], source.token); + assert.strictEqual(manage.firstCall.args[3].fsPath, root.fsPath); + }); + + test('returns partial creation and reuses its directory rather than creating a suffixed environment', async () => { + const partial = await addEnvironment(path.join(root.fsPath, '.venv')); + environments.clear(); + await fs.remove(partial.sysPrefix); + create.callsFake(async () => { + await fs.outputFile(partial.execInfo.run.executable, ''); + await fs.outputFile(path.join(partial.sysPrefix, 'pyvenv.cfg'), 'version = 3.12.4\n'); + environments.set(normalizePath(partial.sysPrefix), partial); + throw new PythonToolError('PACKAGE_INSTALL_FAILED', 'Project dependency installation failed', partial); + }); + const failed = await api.configureEnvironment({}, source.token); + assert.strictEqual(failed.status === 'error' && failed.code, 'PACKAGE_INSTALL_FAILED'); + assert.strictEqual(failed.environment, partial); + const retry = await api.configureEnvironment({}, source.token); + assert.strictEqual(retry.environment, partial); + assert.ok(create.calledOnce); + }); + + test('rejects invalid requests and package options without entering manager operations', async () => { + for (const request of [ + null, + [], + { resourcePath: '' }, + { resourcePath: 'relative' }, + { resourcePath: 'https://example.org/project' }, + { resourcePath: 42 }, + ]) { + const result = await invoke('getEnvironment', request); + assert.strictEqual(result.status === 'error' && result.code, 'INVALID_REQUEST'); + } + for (const packages of [[], [''], ['--index-url'], ['a\nb'], [42], 'requests']) { + const result = await invoke('installPackages', { packages }); + assert.strictEqual(result.status === 'error' && result.code, 'INVALID_REQUEST'); + } + assert.strictEqual((await invoke('getEnvironment', { includePackages: 'yes' })).status, 'error'); + assert.ok(create.notCalled); + assert.ok(manage.notCalled); + }); + + test('reports workspace ambiguity with paths and never guesses a root', async () => { + const other = Uri.file(path.join(temp, 'other')); + folders.returns([ + { uri: root, name: 'workspace', index: 0 }, + { uri: other, name: 'other', index: 1 }, + ]); + const result = await api.configureEnvironment({}, source.token); + assert.strictEqual(result.status === 'error' && result.code, 'AMBIGUOUS_RESOURCE'); + assert.ok( + result.status === 'error' && result.message.includes(root.fsPath) && result.message.includes(other.fsPath), + ); + folders.returns([]); + assert.strictEqual((await api.configureEnvironment({}, source.token)).status, 'error'); + assert.ok(create.notCalled); + }); + + test('enforces trust before executing queries or mutations', async () => { + (workspaceApis.isWorkspaceTrusted as sinon.SinonStub).returns(false); + for (const method of ['getEnvironment', 'configureEnvironment', 'installPackages'] as const) { + const result = await invoke(method, { packages: ['requests'] }); + assert.strictEqual(result.status === 'error' && result.code, 'WORKSPACE_UNTRUSTED'); + } + assert.ok((venv.get as sinon.SinonStub).notCalled); + }); + + test('bounds real discovery and does not report cached fallback as ready', async () => { + const clock = sinon.useFakeTimers(); + const started = createDeferred(); + (venv[pythonToolSupport].initialize as sinon.SinonStub).callsFake(() => { + started.resolve(); + return new Promise(() => {}); + }); + const pending = api.configureEnvironment({}, source.token); + await started.promise; + await clock.tickAsync(30_001); + const result = await pending; + assert.strictEqual(result.status === 'error' && result.code, 'NOT_READY'); + assert.ok(create.notCalled); + }); + + test('throws real cancellation before starting and after creation without selecting', async () => { + source.cancel(); + await assert.rejects(api.configureEnvironment({}, source.token), CancellationError); + source.dispose(); + source = new CancellationTokenSource(); + create.callsFake(async (scope: Uri) => { + const env = await addEnvironment(path.join(scope.fsPath, '.venv')); + source.cancel(); + return env; + }); + await assert.rejects(api.configureEnvironment({}, source.token), CancellationError); + assert.ok((venv.set as sinon.SinonStub).notCalled); + assert.strictEqual(projectSettings.length, 0); + }); + + test('cleanup failure is an error with the selected environment even when its token is cancelled', async () => { + const configured = await api.configureEnvironment({}, source.token); + manage.callsFake(async () => { + source.cancel(); + throw new ProcessTerminationError('python', new CancellationError(), new Error('Access denied')); + }); + const result = await api.installPackages({ packages: ['requests'] }, source.token); + assert.strictEqual(result.status === 'error' && result.code, 'PROCESS_TERMINATION_FAILED'); + assert.strictEqual(result.environment, configured.environment); + assert.ok(result.status === 'error' && result.message.includes('may still be modifying')); + }); +}); diff --git a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts index b789baee8..c56db0043 100644 --- a/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts +++ b/src/test/managers/builtin/helpers.isUvInstalled.unit.test.ts @@ -1,12 +1,18 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { LogOutputChannel, Uri, WorkspaceFolder } from 'vscode'; +import { CancellationError, CancellationTokenSource, LogOutputChannel, Uri, WorkspaceFolder } from 'vscode'; import * as childProcessApis from '../../../common/childProcess.apis'; import * as workspaceApis from '../../../common/workspace.apis'; import { EventNames } from '../../../common/telemetry/constants'; import * as telemetrySender from '../../../common/telemetry/sender'; -import { getUvExecutable, isUvInstalled, resetUvInstallationCache } from '../../../managers/builtin/helpers'; +import { + getUvExecutable, + isUvInstalled, + resetUvInstallationCache, + runUV, + setUvExecutable, +} from '../../../managers/builtin/helpers'; import { createMockLogOutputChannel } from '../../mocks/helper'; import { MockChildProcess } from '../../mocks/mockChildProcess'; @@ -17,7 +23,7 @@ suite('Helpers - isUvInstalled', () => { setup(() => { // Reset UV installation cache before each test to ensure clean state - resetUvInstallationCache(); + setUvExecutable('uv'); mockLog = createMockLogOutputChannel(); @@ -30,6 +36,59 @@ suite('Helpers - isUvInstalled', () => { teardown(() => { sinon.restore(); + setUvExecutable('uv'); + }); + + test('retains a verified installation path across cache resets and uv operations', async () => { + const executable = path.resolve('uv install', process.platform === 'win32' ? 'uv.exe' : 'uv'); + setUvExecutable(executable); + const probe = new MockChildProcess(executable, ['--version']); + spawnStub.withArgs(executable, ['--version']).returns(probe); + const first = getUvExecutable(mockLog); + probe.emit('exit', 0, null); + assert.strictEqual(await first, executable); + + resetUvInstallationCache(); + const second = getUvExecutable(mockLog); + probe.emit('exit', 0, null); + assert.strictEqual(await second, executable); + + const operation = new MockChildProcess(executable, ['pip', 'list']); + spawnStub.withArgs(executable, ['pip', 'list']).returns(operation); + const result = runUV(['pip', 'list']); + operation.stdout?.emit('data', '[]'); + operation.emit('exit', 0, null); + operation.emit('close', 0, null); + assert.strictEqual(await result, '[]'); + assert(spawnStub.alwaysCalledWith(executable)); + }); + + test('does not let an old PATH probe overwrite availability after installation', async () => { + const oldProcess = new MockChildProcess('uv', ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(oldProcess); + const oldProbe = isUvInstalled(); + + const executable = path.resolve('installed', process.platform === 'win32' ? 'uv.exe' : 'uv'); + setUvExecutable(executable); + const installedProcess = new MockChildProcess(executable, ['--version']); + spawnStub.withArgs(executable, ['--version']).returns(installedProcess); + const installedProbe = isUvInstalled(); + oldProcess.emit('error', new Error('ENOENT')); + installedProcess.emit('exit', 0, null); + + assert.strictEqual(await oldProbe, false); + assert.strictEqual(await installedProbe, true); + assert.strictEqual(await getUvExecutable(), executable); + }); + + test('does not report a missing installed executable as available', async () => { + const executable = path.resolve('missing', process.platform === 'win32' ? 'uv.exe' : 'uv'); + setUvExecutable(executable); + const proc = new MockChildProcess(executable, ['--version']); + spawnStub.withArgs(executable, ['--version']).returns(proc); + const result = getUvExecutable(); + proc.emit('error', new Error('ENOENT')); + assert.strictEqual(await result, undefined); }); test('should return true when uv --version succeeds', async () => { @@ -236,6 +295,41 @@ suite('Helpers - isUvInstalled', () => { sinon.assert.calledOnce(spawnStub); }); + test('does not probe uv for a pre-cancelled tool request', async () => { + const source = new CancellationTokenSource(); + try { + source.cancel(); + await assert.rejects(getUvExecutable(mockLog, process.cwd(), source.token), CancellationError); + sinon.assert.notCalled(spawnStub); + } finally { + source.dispose(); + } + }); + + test('cancels a tool request while waiting for the workspace uv probe', async () => { + const root = Uri.file(path.join(process.cwd(), 'project')); + const executable = path.join(root.fsPath, '.pyprojectx', 'main', process.platform === 'win32' ? 'uv.exe' : 'uv'); + sinon.stub(workspaceApis, 'isWorkspaceTrusted').returns(true); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns({ name: 'project', uri: root, index: 0 }); + const globalProc = new MockChildProcess('uv', ['--version']); + const localProc = new MockChildProcess(executable, ['--version']); + spawnStub.withArgs('uv', ['--version']).returns(globalProc); + spawnStub.withArgs(executable, ['--version']).returns(localProc); + const source = new CancellationTokenSource(); + try { + const result = getUvExecutable(mockLog, root.fsPath, source.token); + const rejected = assert.rejects(result, CancellationError); + globalProc.emit('error', new Error('ENOENT')); + await new Promise((resolve) => setImmediate(resolve)); + sinon.assert.calledWith(spawnStub, executable, ['--version']); + source.cancel(); + await rejected; + localProc.emit('exit', 0, null); + } finally { + source.dispose(); + } + }); + test('prefers uv on PATH without looking up a workspace executable', async () => { const getWorkspaceFolder = sinon.stub(workspaceApis, 'getWorkspaceFolder'); const globalProc = new MockChildProcess('uv', ['--version']); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 6eb3954a6..5ac2a5268 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -7,7 +7,15 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationTokenSource, Disposable, LogOutputChannel, Memento, TextDocument, Uri } from 'vscode'; +import { + CancellationError, + CancellationTokenSource, + Disposable, + LogOutputChannel, + Memento, + TextDocument, + Uri, +} from 'vscode'; import { DidChangeEnvironmentEventArgs, EnvironmentChangeKind, @@ -36,6 +44,7 @@ import * as uvPythonInstaller from '../../../../managers/builtin/uvPythonInstall import * as venvUtils from '../../../../managers/builtin/venvUtils'; import { NativePythonFinder } from '../../../../managers/common/nativePythonFinder'; import { MockDocument } from '../../../mocks/mockDocument'; +import { PythonToolError, PythonToolOperation, pythonToolSupport } from '../../../../internal/pythonToolSupport'; const CACHE_KEY = '0123456789abcdef'; const NOW = new Date('2026-07-21T12:00:00.000Z'); @@ -231,6 +240,7 @@ suite('InlineScriptEnvManager', () => { renameFilesListener = undefined; }); }); + sinon.stub(workspaceApis, 'onDidChangeTextDocument').returns(new Disposable(() => undefined)); openDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]); createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => { const envDir = args[6] as string; @@ -500,6 +510,117 @@ suite('InlineScriptEnvManager', () => { }; } + suite('private tool operations', () => { + let source: CancellationTokenSource; + let operation: PythonToolOperation; + + setup(() => { + source = new CancellationTokenSource(); + operation = { + token: source.token, + getGlobalEnvironments: async () => [baseEnvironment], + managePackages: sinon.stub().resolves(), + }; + }); + + teardown(() => { + source.dispose(); + }); + + test('tool applicability recognizes saved metadata before a human association exists', async () => { + assert.strictEqual(routingRegistry.shouldRoute(scriptUri()), false); + assert.strictEqual(await manager[pythonToolSupport].canConfigure!(scriptUri()), true); + assert.ok(readMetadataStub.calledWith(scriptUri(), true)); + assert.strictEqual(routingRegistry.shouldRoute(scriptUri()), false); + readMetadataStub.resolves(undefined); + assert.strictEqual(await manager[pythonToolSupport].canConfigure!(scriptUri()), false); + readMetadataStub.rejects(new Error('Invalid metadata')); + await assert.rejects( + manager[pythonToolSupport].canConfigure!(scriptUri()), + (error) => error instanceof PythonToolError && error.code === 'INVALID_PROJECT', + ); + assert.ok(createWithProgressStub.notCalled && promptInstallPythonViaUvStub.notCalled); + }); + + test('shares cache and metadata creation without querying interactive public environment discovery', async () => { + const environment = await manager[pythonToolSupport].create!(scriptUri(), operation); + assert.ok(environment); + assert.ok(apiGetEnvironmentsStub.notCalled); + assert.strictEqual(createWithProgressStub.firstCall.args[9].operation, operation); + const checkCancellation = lockStub.firstCall.args[1].checkCancellation; + assert.strictEqual(typeof checkCancellation, 'function'); + checkCancellation(); + source.cancel(); + assert.throws(checkCancellation, CancellationError); + assert.ok(writeMetaStub.calledOnce); + assert.ok(promptInstallPythonViaUvStub.notCalled); + }); + + test('reports missing compatible Python without attempting an installation question', async () => { + operation.getGlobalEnvironments = async () => []; + await assert.rejects( + manager[pythonToolSupport].create!(scriptUri(), operation), + (error: unknown) => error instanceof PythonToolError && error.code === 'MISSING_PYTHON', + ); + assert.ok(promptInstallPythonViaUvStub.notCalled); + assert.ok(getAvailablePythonVersionsStub.notCalled); + assert.ok(createWithProgressStub.notCalled); + }); + + test('does not wait for or cancel a human setup that owns the same cache entry', async () => { + const started = createDeferred(); + const release = createDeferred(); + createWithProgressStub.callsFake(async () => { + started.resolve(); + await release.promise; + return undefined; + }); + let humanFinished = false; + const human = manager.create(scriptUri(), { quickCreate: true }).then(() => { + humanFinished = true; + }); + await started.promise; + try { + await assert.rejects( + manager[pythonToolSupport].create!(scriptUri('second.py'), operation), + (error: unknown) => error instanceof PythonToolError && error.code === 'ENVIRONMENT_BUSY', + ); + assert.strictEqual(humanFinished, false); + assert.ok(createWithProgressStub.calledOnce); + } finally { + release.resolve(); + await human; + } + }); + + test('propagates creation cancellation and does not publish cache metadata', async () => { + createWithProgressStub.callsFake(async () => { + source.cancel(); + throw new CancellationError(); + }); + await assert.rejects(manager[pythonToolSupport].create!(scriptUri(), operation), CancellationError); + assert.ok(writeMetaStub.notCalled); + assert.ok(releaseLockStub.calledOnce); + }); + + test('retains the cache lock and directory when a process could not be stopped', async () => { + createWithProgressStub.callsFake(async () => { + await fs.outputFile(getVenvPythonPath(envDir().fsPath), ''); + throw new PythonToolError( + 'PROCESS_TERMINATION_FAILED', + 'Process may still be modifying this cache entry.', + ); + }); + await assert.rejects( + manager[pythonToolSupport].create!(scriptUri(), operation), + (error: unknown) => error instanceof PythonToolError && error.code === 'PROCESS_TERMINATION_FAILED', + ); + assert.ok(await fs.pathExists(envDir().fsPath)); + assert.ok(retainLockStub.calledOnce); + assert.ok(writeMetaStub.notCalled); + }); + }); + suite('static metadata and deferred methods', () => { test('exposes creation and removal while leaving generic resolution empty', async () => { const asInterface: EnvironmentManager = manager; @@ -3257,30 +3378,47 @@ suite('InlineScriptEnvManager', () => { }); test('discovers a build that completes after the short retry window', async () => { + clock.restore(); + clock = sinon.useFakeTimers({ now: NOW, toFake: ['Date', 'setTimeout', 'clearTimeout'] }); const sidecar = await makeSidecar(); setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); const retryManager = manager as unknown as { getDiscoveryRetryDelayMs(attempt: number): number | undefined; }; - assert.strictEqual(retryManager.getDiscoveryRetryDelayMs(2), 30_000); - const retryDelayStub = sinon - .stub(retryManager, 'getDiscoveryRetryDelayMs') - .callsFake((attempt) => (attempt < 2 ? 0 : attempt === 2 ? 25 : undefined)); + const getRetryDelay = retryManager.getDiscoveryRetryDelayMs.bind(retryManager); + const scheduledRetries = [createDeferred(), createDeferred(), createDeferred()]; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').callsFake((attempt) => { + scheduledRetries[attempt]?.resolve(); + return getRetryDelay(attempt); + }); const lockPath = `${path.resolve(envDir().fsPath)}.lock`; await fs.ensureDir(lockPath); manager.startActivationDiscovery(); - await waitForStubCallCount(retryDelayStub, 3); + await scheduledRetries[0].promise; + await clock.tickAsync(1_000); + await scheduledRetries[1].promise; + await clock.tickAsync(5_000); + await scheduledRetries[2].promise; + sinon.assert.notCalled(resolveVenvStub); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + const environment = await createOwnedEnvironment(); resolveVenvStub.resolves(environment); await fs.remove(lockPath); - await waitForStubCall(resolveVenvStub); - await waitForCondition( - async () => (await manager.getEnvironments('all')).length === 1, - 'Expected the extended retry to publish the completed build', - ); - assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + const discovered = createDeferred(); + const listener = manager.onDidChangeEnvironments(() => discovered.resolve()); + try { + await clock.tickAsync(29_999); + sinon.assert.notCalled(resolveVenvStub); + await clock.tickAsync(1); + await discovered.promise; + sinon.assert.calledOnce(resolveVenvStub); + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + } finally { + listener.dispose(); + } }); test('explicit refresh does not schedule a delayed follow-up after an uncertain pass', async () => { @@ -7720,9 +7858,12 @@ suite('InlineScriptEnvManager', () => { test('recovers an old usable environment after a single stamp failure without a save', async () => { const uri = scriptUri('recover.py'); + const scriptText = '# /// script\n# requires-python = ">=3.11"\n# dependencies = ["requests"]\n# ///\n'; + const metadata = metadataReader.readInlineScriptMetadata(scriptText)!; + readMetadataStub.resolves(metadata); const environment = await createOwnedEnvironment(); await manager.set(uri, environment); - await triggerSavedMetadataChange(routingRegistry, manager, uri); + await triggerSavedMetadataChange(routingRegistry, manager, uri, metadata); setSidecar( await makeSidecar({ lastUsedAt: new Date(NOW.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(), @@ -7738,7 +7879,7 @@ suite('InlineScriptEnvManager', () => { const provider = new InlineScriptCodeLensProvider(routingRegistry, 'setup'); try { const document = new MockDocument( - '# /// script\n# dependencies = ["requests"]\n# ///\n', + scriptText, uri.fsPath, async () => true, ); @@ -7754,7 +7895,10 @@ suite('InlineScriptEnvManager', () => { await ready.promise; assert.strictEqual(await manager.get(uri), environment); - assert.strictEqual(provider.provideCodeLenses(document, {} as never).length, 0); + const lenses = provider.provideCodeLenses(document, {} as never); + assert.strictEqual(lenses.length, 1); + assert.strictEqual(lenses[0].command?.command, ''); + assert.strictEqual(lenses[0].command?.title, 'Script environment ready (Python 3.12.4)'); assert.strictEqual(writeMetaStub.callCount, 2); sinon.assert.notCalled(createWithProgressStub); } finally { diff --git a/src/test/managers/builtin/main.unit.test.ts b/src/test/managers/builtin/main.unit.test.ts index 2b41b2109..6359107ab 100644 --- a/src/test/managers/builtin/main.unit.test.ts +++ b/src/test/managers/builtin/main.unit.test.ts @@ -2,9 +2,10 @@ // Licensed under the MIT License. import * as assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import { Disposable, EventEmitter, LogOutputChannel, Uri } from 'vscode'; -import { PythonEnvironmentApi } from '../../../api'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; import * as workspaceApis from '../../../common/workspace.apis'; import * as extensionApi from '../../../extensionApi'; import { registerSystemPythonFeatures } from '../../../managers/builtin/main'; @@ -61,7 +62,9 @@ suite('registerSystemPythonFeatures - activation watcher', () => { test('activation watcher is created with change events enabled and triggers refresh on change', async () => { const mockWatcher = createMockWatcher(); - createFileSystemWatcherStub.returns(mockWatcher); + const deletionWatcher = createMockWatcher(); + createFileSystemWatcherStub.withArgs('{**/activate}').returns(mockWatcher); + createFileSystemWatcherStub.withArgs('**/*').returns(deletionWatcher); const disposables: Disposable[] = []; const nativeFinder = {} as NativePythonFinder; @@ -76,7 +79,7 @@ suite('registerSystemPythonFeatures - activation watcher', () => { await registerSystemPythonFeatures(nativeFinder, disposables, log, envManager); // Assertion A: Watcher is created with [false, false, false] - assert.strictEqual(createFileSystemWatcherStub.callCount, 1); + assert.strictEqual(createFileSystemWatcherStub.callCount, 2); assert.deepStrictEqual(createFileSystemWatcherStub.firstCall.args, ['{**/activate}', false, false, false]); // Assertion B & C: CHANGE event triggers debounced watcherRefresh @@ -112,4 +115,83 @@ suite('registerSystemPythonFeatures - activation watcher', () => { mockWatcher._deleteEmitter.dispose(); disposables.forEach((d) => d.dispose()); }); + + test('deleting a folder that holds a known venv triggers refresh', async () => { + const deletionWatcher = createMockWatcher(); + createFileSystemWatcherStub.withArgs('{**/activate}').returns(createMockWatcher()); + createFileSystemWatcherStub.withArgs('**/*').returns(deletionWatcher); + const venvFolder = Uri.file(path.resolve('workspace', '.venv')); + sandbox + .stub(VenvManager.prototype, 'hasEnvironmentAt') + .callsFake((fsPath: string) => fsPath === venvFolder.fsPath); + + const disposables: Disposable[] = []; + await registerSystemPythonFeatures( + {} as NativePythonFinder, + disposables, + {} as LogOutputChannel, + {} as SysPythonManager, + ); + + // Only deletions are watched: the folder is reported, not the files in it. + assert.ok(createFileSystemWatcherStub.calledWithExactly('**/*', true, true, false)); + + deletionWatcher._deleteEmitter.fire(Uri.file(path.resolve('workspace', 'notes.txt'))); + await clock.tickAsync(600); + assert.strictEqual(watcherRefreshStub.callCount, 0); + + deletionWatcher._deleteEmitter.fire(venvFolder); + await clock.tickAsync(600); + assert.strictEqual(watcherRefreshStub.callCount, 1); + + disposables.forEach((d) => d.dispose()); + }); +}); + +suite('VenvManager.hasEnvironmentAt', () => { + const project = path.resolve('venv-delete-project'); + const venvPrefix = path.join(project, '.venv'); + + function createManager(): VenvManager { + const manager = new VenvManager( + {} as NativePythonFinder, + {} as PythonEnvironmentApi, + {} as SysPythonManager, + {} as LogOutputChannel, + ); + (manager as unknown as { collection: PythonEnvironment[] }).collection = [ + { sysPrefix: venvPrefix } as PythonEnvironment, + ]; + return manager; + } + + test('is true for the venv folder and its parent folders', () => { + const manager = createManager(); + + assert.strictEqual(manager.hasEnvironmentAt(venvPrefix), true); + assert.strictEqual(manager.hasEnvironmentAt(project), true); + }); + + test('is false for unrelated paths and paths inside the venv', () => { + const manager = createManager(); + + assert.strictEqual(manager.hasEnvironmentAt(path.join(project, 'src')), false); + assert.strictEqual(manager.hasEnvironmentAt(path.join(venvPrefix, 'Lib', 'site-packages', 'pkg')), false); + assert.strictEqual(manager.hasEnvironmentAt(path.resolve('venv-delete-project-other')), false); + assert.strictEqual(manager.hasEnvironmentAt(`${venvPrefix}2`), false); + }); + + test('follows changes to the known environments', () => { + const manager = createManager(); + const otherPrefix = path.resolve('venv-delete-other', '.venv'); + assert.strictEqual(manager.hasEnvironmentAt(otherPrefix), false); + + const state = manager as unknown as { collection: PythonEnvironment[] }; + state.collection.push({ sysPrefix: otherPrefix } as PythonEnvironment); + assert.strictEqual(manager.hasEnvironmentAt(otherPrefix), true); + + state.collection = []; + assert.strictEqual(manager.hasEnvironmentAt(venvPrefix), false); + assert.strictEqual(manager.hasEnvironmentAt(otherPrefix), false); + }); }); diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 866570626..c1765b020 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -194,14 +194,28 @@ suite('PipPackageManager', () => { test('uses UV version lookup without querying the Pip version', async () => { const manager = createManager(); const environment = createEnvironment(); - sinon.stub(helpers, 'shouldUseUv').resolves(true); - sinon.stub(helpers, 'getUvExecutable').resolves('uv'); + const shouldUseUv = sinon.stub(helpers, 'shouldUseUv').resolves(true); + const getUvExecutable = sinon.stub(helpers, 'getUvExecutable').resolves('workspace-uv'); const runPython = sinon.stub(helpers, 'runPython'); - sinon.stub(helpers, 'runUV').resolves(JSON.stringify({ versions: ['2.32.5', '2.31.0'] })); + const runUV = sinon + .stub(helpers, 'runUV') + .resolves(JSON.stringify({ versions: ['2.32.5', '2.31.0'] })); const versions = await manager.getPackageAvailableVersions(environment, 'requests'); assert.ok(runPython.notCalled); + assert.ok( + shouldUseUv.calledOnceWithExactly( + manager.log, + environment.environmentPath.fsPath, + environment.environmentPath.fsPath, + undefined, + ), + ); + assert.ok( + getUvExecutable.calledOnceWithExactly(manager.log, environment.environmentPath.fsPath, undefined), + ); + assert.strictEqual(runUV.firstCall.args[5], 'workspace-uv'); assert.deepStrictEqual( versions.map((version) => version.public), ['2.32.5', '2.31.0'], diff --git a/src/test/managers/builtin/pipUtils.unit.test.ts b/src/test/managers/builtin/pipUtils.unit.test.ts index ac6fb7805..a6e92b6de 100644 --- a/src/test/managers/builtin/pipUtils.unit.test.ts +++ b/src/test/managers/builtin/pipUtils.unit.test.ts @@ -1,7 +1,7 @@ import assert from 'assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationToken, LogOutputChannel, Progress, ProgressLocation, ProgressOptions, Uri } from 'vscode'; +import { CancellationError, CancellationToken, CancellationTokenSource, LogOutputChannel, Progress, ProgressLocation, ProgressOptions, Uri } from 'vscode'; import * as fse from 'fs-extra'; import * as os from 'os'; import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; @@ -54,6 +54,28 @@ suite('Pip Utils - getProjectInstallable', () => { sinon.restore(); }); + test('ordinary search retains its cancellation behavior while tool search rejects cancellation', async () => { + const source = new CancellationTokenSource(); + source.cancel(); + findFilesStub.resolves([]); + withProgressStub.callsFake(async (_options, task) => task({ report: () => {} }, source.token)); + const projects = [{ name: 'workspace', uri: Uri.file(process.cwd()) }]; + try { + const ordinary = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects); + assert.deepStrictEqual(ordinary.installables, []); + await assert.rejects( + getProjectInstallable(mockApi as PythonEnvironmentApi, projects, { + runHeadless: true, + token: source.token, + }), + CancellationError, + ); + assert.ok(withProgressStub.calledOnce); + } finally { + source.dispose(); + } + }); + test('opens the package picker when listing installed packages fails', async () => { const listError = new Error('pip list failed'); const logError = sinon.stub(); @@ -244,6 +266,36 @@ suite('Pip Utils - getProjectInstallable', () => { assert.ok(!findFilesStub.called, 'Should not call findFiles when no projects'); }); + test('private nested creation excludes parent and sibling dependencies before parsing or validating them', async () => { + const temp = await fse.mkdtemp(path.join(os.tmpdir(), 'private-dependency-scope-')); + const source = new CancellationTokenSource(); + try { + const project = { name: 'root', uri: Uri.file(temp) }; + const nested = Uri.file(path.join(temp, 'new service')); + const ownRequirement = Uri.file(path.join(nested.fsPath, 'requirements.txt')); + const rootRequirement = Uri.file(path.join(temp, 'requirements.txt')); + const siblingRequirement = Uri.file(path.join(temp, 'sibling', 'requirements.txt')); + const siblingToml = Uri.file(path.join(temp, 'sibling', 'pyproject.toml')); + const invalidSiblingSyntax = Uri.file(path.join(temp, 'sibling-two', 'pyproject.toml')); + await fse.outputFile(siblingToml.fsPath, '[project]\nname = "sibling"\n[build-system]\nbuild-backend = "missing"\n'); + await fse.outputFile(invalidSiblingSyntax.fsPath, '[invalid syntax'); + mockApi.getPythonProject = () => project; + findFilesStub.resolves([rootRequirement, ownRequirement, siblingRequirement, siblingToml, invalidSiblingSyntax]); + const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, [project], { + runHeadless: true, + token: source.token, + preferredRoot: nested, + deduplicateProjectPackages: true, + }); + assert.deepStrictEqual(result.installables.map((item) => item.uri?.fsPath), [ownRequirement.fsPath]); + assert.strictEqual(result.validationError, undefined); + assert.ok(withProgressStub.notCalled); + } finally { + source.dispose(); + await fse.remove(temp); + } + }); + test('should filter out files not in project directories', async () => { // Arrange: Mock findFiles to return files from multiple directories findFilesStub.callsFake((pattern: string) => { diff --git a/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts b/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts new file mode 100644 index 000000000..8409cb002 --- /dev/null +++ b/src/test/managers/builtin/sysPythonManager.savedSelection.unit.test.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentChangeKind, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; +import * as windowApis from '../../../common/window.apis'; +import * as sysCache from '../../../managers/builtin/cache'; +import { SysPythonManager } from '../../../managers/builtin/sysPythonManager'; +import * as sysUtils from '../../../managers/builtin/utils'; +import * as uvPythonInstaller from '../../../managers/builtin/uvPythonInstaller'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; + +suite('SysPythonManager saved selections', () => { + const projectUri = Uri.file(path.resolve('project')); + const interpreter = path.resolve('uv', 'python', 'cpython-3.13.15-windows-x86_64-none', 'python.exe'); + // A saved path that is an alias of the discovered interpreter, like uv's ~/.local/bin launcher. + const launcher = path.resolve('home', '.local', 'bin', 'python.exe'); + + function makeEnv(executable: string): PythonEnvironment { + return { + envId: { id: `system:${executable}`, managerId: 'ms-python.python:system' }, + name: 'Python 3.13.15 (uv)', + displayName: 'Python 3.13.15 (uv)', + displayPath: executable, + version: '3.13.15', + environmentPath: Uri.file(executable), + sysPrefix: path.dirname(executable), + execInfo: { run: { executable } }, + }; + } + + function createManager(): SysPythonManager { + const project = { uri: projectUri } as PythonProject; + const api = { + getPythonProject: () => project, + getPythonProjects: () => [project], + } as unknown as PythonEnvironmentApi; + const log = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } as unknown as LogOutputChannel; + return new SysPythonManager({} as NativePythonFinder, api, log); + } + + setup(() => { + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(sysUtils, 'refreshPythons').resolves([makeEnv(interpreter)]); + sinon.stub(sysCache, 'getSystemEnvForGlobal').resolves(launcher); + sinon.stub(sysCache, 'getSystemEnvForWorkspace').resolves(launcher); + // The saved launcher resolves to the interpreter that discovery already listed. + sinon.stub(sysUtils, 'resolveSystemPythonEnvironmentPath').callsFake(async () => makeEnv(interpreter)); + }); + + teardown(() => { + sinon.restore(); + }); + + test('does not duplicate an interpreter when a saved path resolves to a discovered one', async () => { + const manager = createManager(); + await manager.initialize(); + + const envs = await manager.getEnvironments('all'); + assert.strictEqual(envs.length, 1); + assert.strictEqual(await manager.get(undefined), envs[0]); + assert.strictEqual(await manager.get(projectUri), envs[0]); + }); + + test('installing an already-installed version reuses the listed interpreter', async () => { + sinon.stub(uvPythonInstaller, 'selectPythonVersionToInstall').resolves('3.13'); + sinon.stub(uvPythonInstaller, 'installPythonWithUv').resolves(interpreter); + sinon.stub(sysCache, 'setSystemEnvForGlobal').resolves(); + const manager = createManager(); + await manager.initialize(); + const [listed] = await manager.getEnvironments('all'); + const added: unknown[] = []; + manager.onDidChangeEnvironments((e) => added.push(...e.filter((c) => c.kind === EnvironmentChangeKind.add))); + + const created = await manager.create('global', undefined); + + assert.strictEqual(created, listed); + assert.strictEqual((await manager.getEnvironments('all')).length, 1); + assert.strictEqual(added.length, 0); + }); +}); diff --git a/src/test/managers/builtin/uvManagedPythonInstall.unit.test.ts b/src/test/managers/builtin/uvManagedPythonInstall.unit.test.ts new file mode 100644 index 000000000..eda496148 --- /dev/null +++ b/src/test/managers/builtin/uvManagedPythonInstall.unit.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../../api'; +import { + getDefaultGlobalPython, + isUvManagedPythonInstall, + refreshPythons, + resolveSystemPythonEnvironmentPath, +} from '../../../managers/builtin/utils'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { findVirtualEnvironments, resolveVenvPythonEnvironmentPath } from '../../../managers/builtin/venvUtils'; +import { + NativeEnvInfo, + NativePythonEnvironmentKind, + NativePythonFinder, +} from '../../../managers/common/nativePythonFinder'; + +suite('uv-managed Python installations', () => { + let tempRoot: string; + let uvInstall: NativeEnvInfo; + let uvVenv: NativeEnvInfo; + let api: PythonEnvironmentApi; + let log: LogOutputChannel; + let systemManager: EnvironmentManager; + let venvManager: EnvironmentManager; + + const executableIn = (prefix: string): string => + process.platform === 'win32' ? path.join(prefix, 'python.exe') : path.join(prefix, 'bin', 'python'); + + const venvExecutableIn = (prefix: string): string => + process.platform === 'win32' ? path.join(prefix, 'Scripts', 'python.exe') : path.join(prefix, 'bin', 'python'); + + function finderReturning(envs: NativeEnvInfo[]): NativePythonFinder { + return { + refresh: sinon.stub().resolves(envs), + resolve: sinon.stub().callsFake(async (executable: string) => { + const found = envs.find((e) => e.executable === executable); + if (!found) { + throw new Error(`unknown executable ${executable}`); + } + return found; + }), + } as unknown as NativePythonFinder; + } + + setup(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'uv-managed-install-')); + + // `uv python install` layout: a base interpreter without pyvenv.cfg. + const installPrefix = path.join(tempRoot, 'uv', 'python', 'cpython-3.13.15-windows-x86_64-none'); + await fs.outputFile(executableIn(installPrefix), ''); + uvInstall = { + kind: NativePythonEnvironmentKind.venvUv, + executable: executableIn(installPrefix), + prefix: installPrefix, + version: '3.13.15', + }; + + // `uv venv` layout: a virtual environment with pyvenv.cfg. + const venvPrefix = path.join(tempRoot, 'project', '.venv'); + await fs.outputFile(venvExecutableIn(venvPrefix), ''); + await fs.outputFile(path.join(venvPrefix, 'pyvenv.cfg'), `home = ${installPrefix}\nuv = 0.12.17\n`); + uvVenv = { + kind: NativePythonEnvironmentKind.venvUv, + name: 'project', + executable: venvExecutableIn(venvPrefix), + prefix: venvPrefix, + version: '3.13.15', + }; + + api = { + createPythonEnvironmentItem: sinon.stub().callsFake( + (info: PythonEnvironmentInfo, manager: EnvironmentManager): PythonEnvironment => ({ + ...info, + envId: { id: `${manager.name}:${info.environmentPath.fsPath}`, managerId: manager.name }, + }), + ), + } as unknown as PythonEnvironmentApi; + log = { + error: sinon.stub(), + warn: sinon.stub(), + info: sinon.stub(), + } as unknown as LogOutputChannel; + systemManager = { name: 'system', log } as unknown as EnvironmentManager; + venvManager = { name: 'venv', log } as unknown as EnvironmentManager; + sinon.stub(uvEnvironments, 'addUvEnvironment').resolves(); + }); + + teardown(async () => { + sinon.restore(); + await fs.remove(tempRoot); + }); + + test('identifies a uv-managed installation by the absence of pyvenv.cfg', async () => { + assert.strictEqual(await isUvManagedPythonInstall(uvInstall), true); + assert.strictEqual(await isUvManagedPythonInstall(uvVenv), false); + }); + + test('ignores other kinds and environments without a prefix', async () => { + assert.strictEqual( + await isUvManagedPythonInstall({ ...uvInstall, kind: NativePythonEnvironmentKind.globalPaths }), + false, + ); + assert.strictEqual(await isUvManagedPythonInstall({ ...uvInstall, prefix: undefined }), false); + }); + + test('Global manager lists a uv-managed installation and labels it as uv', async () => { + const envs = await refreshPythons(false, finderReturning([uvInstall, uvVenv]), api, log, systemManager); + + assert.deepStrictEqual( + envs.map((e) => e.environmentPath.fsPath), + [Uri.file(uvInstall.executable!).fsPath], + ); + assert.strictEqual(envs[0].name, 'Python 3.13.15 (uv)'); + }); + + test('venv manager keeps uv virtual environments but skips uv-managed installations', async () => { + const envs = await findVirtualEnvironments(false, finderReturning([uvInstall, uvVenv]), api, log, venvManager); + + assert.deepStrictEqual( + envs.map((e) => e.environmentPath.fsPath), + [Uri.file(uvVenv.executable!).fsPath], + ); + }); + + test('resolving a uv-managed installation by path returns a Global environment', async () => { + const finder = finderReturning([uvInstall, uvVenv]); + + const install = await resolveVenvPythonEnvironmentPath( + uvInstall.executable!, + finder, + api, + venvManager, + systemManager, + ); + const venv = await resolveVenvPythonEnvironmentPath(uvVenv.executable!, finder, api, venvManager, systemManager); + + assert.strictEqual(install?.envId.managerId, 'system'); + assert.strictEqual(install?.name, 'Python 3.13.15 (uv)'); + assert.strictEqual(venv?.envId.managerId, 'venv'); + }); + + test('Global resolver labels only uv-managed installations as uv', async () => { + const finder = finderReturning([uvInstall, uvVenv]); + + const install = await resolveSystemPythonEnvironmentPath(uvInstall.executable!, finder, api, systemManager); + const venv = await resolveSystemPythonEnvironmentPath(uvVenv.executable!, finder, api, systemManager); + + assert.strictEqual(install?.shortDisplayName, '3.13.15 (uv)'); + assert.strictEqual(venv?.shortDisplayName, '3.13.15'); + }); + + suite('default global Python', () => { + let pythonOrg: NativeEnvInfo; + + setup(async () => { + // An older installation that is not managed by uv (for example from python.org). + const prefix = path.join(tempRoot, 'Python312'); + await fs.outputFile(executableIn(prefix), ''); + pythonOrg = { + kind: NativePythonEnvironmentKind.globalPaths, + executable: executableIn(prefix), + prefix, + version: '3.12.10', + }; + }); + + test('prefers an installation not managed by uv over a newer uv-managed one', async () => { + const envs = await refreshPythons(false, finderReturning([uvInstall, pythonOrg]), api, log, systemManager); + + assert.strictEqual( + getDefaultGlobalPython(envs)?.environmentPath.fsPath, + Uri.file(pythonOrg.executable!).fsPath, + ); + }); + + test('prefers an installation not managed by uv over a uv-managed one resolved by path', async () => { + const finder = finderReturning([uvInstall, pythonOrg]); + const envs = await refreshPythons(false, finderReturning([pythonOrg]), api, log, systemManager); + const resolved = await resolveSystemPythonEnvironmentPath(uvInstall.executable!, finder, api, systemManager); + + assert.strictEqual( + getDefaultGlobalPython([resolved!, ...envs])?.environmentPath.fsPath, + Uri.file(pythonOrg.executable!).fsPath, + ); + }); + + test('uses a uv-managed installation when it is the only Python', async () => { + const envs = await refreshPythons(false, finderReturning([uvInstall]), api, log, systemManager); + + assert.strictEqual( + getDefaultGlobalPython(envs)?.environmentPath.fsPath, + Uri.file(uvInstall.executable!).fsPath, + ); + }); + }); +}); diff --git a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts index cb1a47445..1d886a43f 100644 --- a/src/test/managers/builtin/uvPythonInstaller.unit.test.ts +++ b/src/test/managers/builtin/uvPythonInstaller.unit.test.ts @@ -1,23 +1,31 @@ import assert from 'assert'; +import os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; import { CancellationToken, LogOutputChannel, ShellExecution, TaskExecution, TaskProcessEndEvent } from 'vscode'; import * as childProcessApis from '../../../common/childProcess.apis'; +import * as envApis from '../../../common/env.apis'; import { Common, UvInstallStrings } from '../../../common/localize'; import * as persistentState from '../../../common/persistentState'; import { EventNames } from '../../../common/telemetry/constants'; import * as telemetrySender from '../../../common/telemetry/sender'; import * as taskApis from '../../../common/tasks.apis'; import * as windowApis from '../../../common/window.apis'; +import * as platformUtils from '../../../common/utils/platformUtils'; import * as helpers from '../../../managers/builtin/helpers'; import { clearDontAskAgain, ensureUvForInlineScriptVersionLookupDetailed, ensureUvForInlineScriptVersionLookup, + ensureUvForPythonVersionLookup, getAvailablePythonVersions, getUvPythonPath, isDontAskAgainSet, + installPythonWithUv, + installUv, promptInstallPythonViaUvDetailed, promptInstallPythonViaUv, + selectPythonVersionToInstall, UV_INSTALL_PYTHON_DONT_ASK_KEY, UvPythonVersion, } from '../../../managers/builtin/uvPythonInstaller'; @@ -29,10 +37,12 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { let isUvInstalledStub: sinon.SinonStub; let showErrorMessageStub: sinon.SinonStub; let showInformationMessageStub: sinon.SinonStub; + let launchBrowserStub: sinon.SinonStub; let sendTelemetryEventStub: sinon.SinonStub; let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; setup(() => { + helpers.setUvExecutable('uv'); mockLog = createMockLogOutputChannel(); mockState = { @@ -42,13 +52,16 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { }; sinon.stub(persistentState, 'getGlobalPersistentState').resolves(mockState); isUvInstalledStub = sinon.stub(helpers, 'isUvInstalled'); + sinon.stub(helpers, 'getUvExecutable').resolves('uv'); showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage'); + launchBrowserStub = sinon.stub(envApis, 'launchBrowser').resolves(true); sendTelemetryEventStub = sinon.stub(telemetrySender, 'sendTelemetryEvent'); }); teardown(() => { sinon.restore(); + helpers.setUvExecutable('uv'); }); function stubUvInstallTask(exitCode: number | undefined): sinon.SinonStub { @@ -269,6 +282,109 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { assert(showInformationMessageStub.calledOnce, 'Should offer the requested prerelease'); }); + test('should cancel global Python version lookup without installing uv', async () => { + isUvInstalledStub.resolves(false); + showInformationMessageStub.resolves(undefined); + + assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), false); + sinon.assert.calledOnceWithExactly( + showInformationMessageStub, + UvInstallStrings.installUvForVersionLookupPrompt, + { modal: true }, + UvInstallStrings.installUv, + UvInstallStrings.otherPythonInstallationOptions, + ); + sinon.assert.notCalled(launchBrowserStub); + assert.strictEqual(showErrorMessageStub.callCount, 0); + }); + + test('should open alternate Python installation guidance', async () => { + isUvInstalledStub.resolves(false); + showInformationMessageStub.resolves(UvInstallStrings.otherPythonInstallationOptions); + + assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), false); + sinon.assert.calledOnceWithExactly( + launchBrowserStub, + 'https://code.visualstudio.com/docs/python/python-tutorial#_install-a-python-interpreter', + ); + }); + + test('should install uv for global Python version lookup after consent', async () => { + isUvInstalledStub.onFirstCall().resolves(false); + isUvInstalledStub.onSecondCall().resolves(true); + showInformationMessageStub.resolves(UvInstallStrings.installUv); + const executeTaskStub = stubUvInstallTask(0); + + assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), true); + assert.strictEqual(isUvInstalledStub.callCount, 2); + assert.strictEqual(executeTaskStub.callCount, 1); + }); + + test('should report a failed uv installation for global Python version lookup', async () => { + isUvInstalledStub.resolves(false); + showInformationMessageStub.resolves(UvInstallStrings.installUv); + showErrorMessageStub.resolves(Common.viewLogs); + stubUvInstallTask(1); + + assert.strictEqual(await ensureUvForPythonVersionLookup(mockLog), false); + sinon.assert.calledOnceWithExactly(showErrorMessageStub, UvInstallStrings.uvInstallFailed, Common.viewLogs); + sinon.assert.calledOnce(mockLog.show as sinon.SinonStub); + }); + + test('should install uv before fetching versions and selecting Python', async () => { + const order: string[] = []; + isUvInstalledStub.onFirstCall().resolves(false); + isUvInstalledStub.onSecondCall().resolves(true); + showInformationMessageStub.resolves(UvInstallStrings.installUv); + sinon + .stub(windowApis, 'withProgress') + .callsFake(async (_options, task) => task({ report: () => undefined }, {} as CancellationToken)); + sinon.stub(windowApis, 'showQuickPick').callsFake(async (items) => { + order.push('quickPick'); + return Array.isArray(items) ? items[0] : undefined; + }); + + let taskEndListener: ((event: TaskProcessEndEvent) => unknown) | undefined; + sinon.stub(taskApis, 'onDidEndTaskProcess').callsFake((listener) => { + taskEndListener = listener; + return { dispose: () => undefined }; + }); + sinon.stub(taskApis, 'executeTask').callsFake(async (task) => { + const execution = { task, terminate: () => undefined } as TaskExecution; + setImmediate(() => { + order.push('install'); + taskEndListener?.({ execution, exitCode: 0 } as TaskProcessEndEvent); + }); + return execution; + }); + sinon.stub(childProcessApis, 'spawnProcess').callsFake((command, args) => { + const process = new MockChildProcess(command, args); + if (command === 'uv') { + order.push('list'); + setImmediate(() => { + process.stdout?.emit('data', JSON.stringify([makeUvPythonVersion({ version: '3.13.1' })])); + process.emit('exit', 0, null); + }); + } else { + setImmediate(() => process.emit('exit', 0, null)); + } + return process as unknown as ReturnType; + }); + + assert.strictEqual(await selectPythonVersionToInstall(mockLog), '3.13.1'); + assert.deepStrictEqual(order, ['install', 'list', 'quickPick']); + }); + + test('should not fetch versions when uv installation is declined', async () => { + isUvInstalledStub.resolves(false); + showInformationMessageStub.resolves(undefined); + const spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + + assert.strictEqual(await selectPythonVersionToInstall(mockLog), undefined); + assert.strictEqual(spawnStub.callCount, 0); + assert.strictEqual(showErrorMessageStub.callCount, 0); + }); + test('should request consent before installing uv for version lookup', async () => { isUvInstalledStub.resolves(false); showInformationMessageStub.resolves(undefined); @@ -419,13 +535,10 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { const installAction = UvInstallStrings.installPythonVersion('3.13'); showInformationMessageStub.onFirstCall().resolves(installAction); sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => - task( - { report: () => undefined }, - { - isCancellationRequested: false, - onCancellationRequested: () => ({ dispose: () => undefined }), - } as CancellationToken, - ), + task({ report: () => undefined }, { + isCancellationRequested: false, + onCancellationRequested: () => ({ dispose: () => undefined }), + } as CancellationToken), ); let taskEndListener: ((event: TaskProcessEndEvent) => unknown) | undefined; @@ -440,9 +553,7 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { return { task, terminate: () => undefined } as TaskExecution; }); - const versions: UvPythonVersion[] = [ - makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' }), - ]; + const versions: UvPythonVersion[] = [makeUvPythonVersion({ version: '3.13.1', path: '/usr/bin/python3.13' })]; const mockProcess = new MockChildProcess('uv', [ 'python', 'list', @@ -471,6 +582,197 @@ suite('uvPythonInstaller - promptInstallPythonViaUv', () => { }); }); +suite('uvPythonInstaller - executable handoff', () => { + const home = path.resolve('bootstrap home'); + const installDir = path.join(home, 'custom uv bin'); + let savedEnv: NodeJS.ProcessEnv; + let installed: boolean; + let taskExitCode: number | undefined; + let spawnStub: sinon.SinonStub; + let executeTaskStub: sinon.SinonStub; + let telemetryStub: sinon.SinonStub; + let showInformationMessageStub: sinon.SinonStub; + let expectedExecutable: string; + const pythonPath = path.join(home, 'managed-python', 'python'); + + setup(() => { + savedEnv = process.env; + process.env = { ...savedEnv }; + for (const key of [ + 'UV_INSTALL_DIR', + 'CARGO_DIST_FORCE_INSTALL_DIR', + 'UV_UNMANAGED_INSTALL', + 'CARGO_HOME', + 'XDG_BIN_HOME', + 'XDG_DATA_HOME', + ]) { + delete process.env[key]; + } + process.env.UV_INSTALL_DIR = installDir; + helpers.setUvExecutable('uv'); + sinon.stub(os, 'homedir').returns(home); + sinon.stub(platformUtils, 'isWindows').returns(false); + installed = false; + taskExitCode = 0; + expectedExecutable = path.join(installDir, 'uv'); + telemetryStub = sinon.stub(telemetrySender, 'sendTelemetryEvent'); + sinon.stub(windowApis, 'showErrorMessage'); + showInformationMessageStub = sinon.stub(windowApis, 'showInformationMessage'); + sinon + .stub(windowApis, 'withProgress') + .callsFake(async (_options, task) => + task( + { report: () => undefined }, + { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => undefined }) }, + ), + ); + let taskEndListener: ((event: TaskProcessEndEvent) => unknown) | undefined; + sinon.stub(taskApis, 'onDidEndTaskProcess').callsFake((listener) => { + taskEndListener = listener; + return { dispose: () => undefined }; + }); + executeTaskStub = sinon.stub(taskApis, 'executeTask').callsFake(async (task) => { + const execution = { task, terminate: () => undefined } as TaskExecution; + setImmediate(() => { + if (task.name === UvInstallStrings.installingUv && taskExitCode === 0) { + installed = true; + } + taskEndListener?.({ execution, exitCode: taskExitCode }); + }); + return execution; + }); + spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + spawnStub.callsFake((command: string, args: string[]) => { + const proc = new MockChildProcess(command, args); + setImmediate(() => { + if (command === 'curl' || command === 'wget') { + proc.emit('exit', 0, null); + } else if (installed && command === expectedExecutable) { + if (args[0] === 'python') { + proc.stdout?.emit( + 'data', + JSON.stringify([makeUvPythonVersion({ version: '3.11.15', path: pythonPath })]), + ); + } + proc.emit('exit', 0, null); + } else { + proc.emit('error', new Error(`spawn ${command} ENOENT`)); + } + }); + return proc; + }); + }); + + teardown(() => { + process.env = savedEnv; + sinon.restore(); + helpers.setUvExecutable('uv'); + }); + + test('installs Python and lists versions with the installed executable when PATH is unchanged', async () => { + const originalPath = process.env.PATH; + assert.strictEqual(await installPythonWithUv(undefined, '3.11'), pythonPath); + assert.strictEqual(process.env.PATH, originalPath); + const execution = executeTaskStub.secondCall.args[0].execution as ShellExecution; + assert.strictEqual(execution.command, expectedExecutable); + assert.deepStrictEqual(execution.args, ['python', 'install', '3.11']); + sinon.assert.calledWith(spawnStub, expectedExecutable, [ + 'python', + 'list', + '--only-installed', + '--managed-python', + '--output-format', + 'json', + ]); + assert.strictEqual((await getAvailablePythonVersions()).length, 1); + sinon.assert.calledWith(spawnStub, expectedExecutable, ['python', 'list', '--output-format', 'json']); + assert.strictEqual(await helpers.getUvExecutable(), expectedExecutable); + sinon.assert.calledWith(telemetryStub, EventNames.UV_PYTHON_INSTALL_COMPLETED); + assert(!telemetryStub.calledWith(EventNames.UV_PYTHON_INSTALL_FAILED)); + }); + + test('uses the installed executable to populate the Global version picker when PATH is unchanged', async () => { + const originalPath = process.env.PATH; + showInformationMessageStub.resolves(UvInstallStrings.installUv); + sinon.stub(windowApis, 'showQuickPick').callsFake(async (items) => (Array.isArray(items) ? items[0] : undefined)); + + assert.strictEqual(await selectPythonVersionToInstall(), '3.11.15'); + + assert.strictEqual(process.env.PATH, originalPath); + sinon.assert.calledWith(spawnStub, expectedExecutable, ['--version']); + sinon.assert.calledWith(spawnStub, expectedExecutable, ['python', 'list', '--output-format', 'json']); + assert.strictEqual(await helpers.getUvExecutable(), expectedExecutable); + }); + + test('does not replace executable resolution after an installer failure', async () => { + taskExitCode = 1; + assert.strictEqual(await installPythonWithUv(), undefined); + assert.strictEqual(await helpers.getUvExecutable(), undefined); + assert(!spawnStub.calledWith(expectedExecutable)); + sinon.assert.calledWith(telemetryStub, EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { stage: 'uvInstall' }); + }); + + test('reports failure when a successful installer does not leave a runnable executable', async () => { + expectedExecutable = path.join(home, 'not-the-installed-binary'); + assert.strictEqual(await installPythonWithUv(), undefined); + sinon.assert.calledOnce(executeTaskStub); + sinon.assert.calledWith(telemetryStub, EventNames.UV_PYTHON_INSTALL_FAILED, undefined, { + stage: 'uvNotOnPath', + }); + }); + + test('uses the installed executable after consenting to inline-script version lookup', async () => { + (windowApis.showInformationMessage as sinon.SinonStub).resolves(UvInstallStrings.installUv); + assert.strictEqual(await ensureUvForInlineScriptVersionLookup('>=3.11'), true); + assert.strictEqual((await getAvailablePythonVersions()).length, 1); + sinon.assert.calledWith(spawnStub, expectedExecutable, ['python', 'list', '--output-format', 'json']); + }); + + const destinations: { name: string; env: NodeJS.ProcessEnv; directory: string }[] = [ + { name: 'default home', env: {}, directory: path.join(home, '.local', 'bin') }, + { + name: 'explicit override', + env: { UV_INSTALL_DIR: installDir, XDG_BIN_HOME: path.join(home, 'ignored') }, + directory: installDir, + }, + { name: 'legacy override', env: { CARGO_DIST_FORCE_INSTALL_DIR: installDir }, directory: installDir }, + { name: 'unmanaged install', env: { UV_UNMANAGED_INSTALL: installDir }, directory: installDir }, + { + name: 'XDG bin', + env: { XDG_BIN_HOME: installDir, XDG_DATA_HOME: path.join(home, 'ignored') }, + directory: installDir, + }, + { + name: 'XDG data', + env: { XDG_DATA_HOME: path.join(home, 'data', 'share') }, + directory: path.join(home, 'data', 'bin'), + }, + { + name: 'legacy default Cargo home', + env: { UV_INSTALL_DIR: path.join(home, '.cargo') }, + directory: path.join(home, '.cargo', 'bin'), + }, + { + name: 'custom Cargo home', + env: { UV_INSTALL_DIR: installDir, CARGO_HOME: installDir }, + directory: path.join(installDir, 'bin'), + }, + ]; + for (const windows of [false, true]) { + for (const { name, env, directory } of destinations) { + test(`resolves ${name} with ${windows ? 'Windows' : 'POSIX'} executable naming`, async () => { + delete process.env.UV_INSTALL_DIR; + Object.assign(process.env, env); + (platformUtils.isWindows as sinon.SinonStub).returns(windows); + expectedExecutable = path.join(directory, windows ? 'uv.exe' : 'uv'); + assert.strictEqual(await installUv(), true); + assert.strictEqual(await helpers.getUvExecutable(), expectedExecutable); + sinon.assert.calledWith(spawnStub, expectedExecutable, ['--version']); + }); + } + } +}); + suite('uvPythonInstaller - isDontAskAgainSet and clearDontAskAgain', () => { let mockState: { get: sinon.SinonStub; set: sinon.SinonStub; clear: sinon.SinonStub }; @@ -541,6 +843,7 @@ suite('uvPythonInstaller - getUvPythonPath', () => { setup(() => { spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + sinon.stub(helpers, 'getUvExecutable').resolves('uv'); }); teardown(() => { @@ -834,6 +1137,7 @@ suite('uvPythonInstaller - getAvailablePythonVersions', () => { setup(() => { spawnStub = sinon.stub(childProcessApis, 'spawnProcess'); + sinon.stub(helpers, 'getUvExecutable').resolves('uv'); }); teardown(() => { diff --git a/src/test/managers/builtin/venvManager.autoSelect.unit.test.ts b/src/test/managers/builtin/venvManager.autoSelect.unit.test.ts new file mode 100644 index 000000000..a71a96c77 --- /dev/null +++ b/src/test/managers/builtin/venvManager.autoSelect.unit.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; +import { VENV_MANAGER_ID } from '../../../common/constants'; +import { normalizePath } from '../../../common/utils/pathUtils'; +import * as windowApis from '../../../common/window.apis'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('VenvManager auto-selection for a project', () => { + const projectUri = Uri.file(path.resolve('workspace', 'project')); + const bin = process.platform === 'win32' ? 'Scripts' : 'bin'; + + function venvIn(...segments: string[]): string { + return Uri.file(path.join(projectUri.fsPath, ...segments, '.venv')).fsPath; + } + + function makeVenv(prefix: string, version: string): PythonEnvironment { + return createMockPythonEnvironment({ + id: prefix, + name: '.venv', + envPath: path.join(prefix, bin, 'python'), + sysPrefix: prefix, + version, + managerId: VENV_MANAGER_ID, + }); + } + + async function createManager(): Promise { + const project = { name: 'project', uri: projectUri } as PythonProject; + const projectRoot = normalizePath(projectUri.fsPath); + const api = { + getPythonProject: (uri: Uri) => { + const p = normalizePath(uri.fsPath); + return p === projectRoot || p.startsWith(`${projectRoot}/`) ? project : undefined; + }, + getPythonProjects: () => [project], + } as unknown as PythonEnvironmentApi; + const baseManager = { getEnvironments: sinon.stub().resolves([]) } as unknown as EnvironmentManager; + const log = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } as unknown as LogOutputChannel; + const manager = new VenvManager({} as NativePythonFinder, api, baseManager, log); + await manager.initialize(); + return manager; + } + + setup(() => { + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(venvUtils, 'getVenvForWorkspace').resolves(undefined); + sinon.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + }); + + teardown(() => { + sinon.restore(); + }); + + test("prefers the project's own .venv over a newer one in a subfolder", async () => { + const rootVenv = makeVenv(venvIn(), '3.12.14'); + const toolsVenv = makeVenv(venvIn('tools'), '3.13.15'); + sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([toolsVenv, rootVenv]); + const manager = await createManager(); + + assert.strictEqual((await manager.get(projectUri))?.envId.id, rootVenv.envId.id); + }); + + test('still selects a nested environment when the project root has none', async () => { + const toolsVenv = makeVenv(venvIn('tools'), '3.13.15'); + sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([toolsVenv]); + const manager = await createManager(); + + assert.strictEqual((await manager.get(projectUri))?.envId.id, toolsVenv.envId.id); + }); + + test('does not prefer a broken root environment over a working nested one', async () => { + const brokenRoot = Object.assign(makeVenv(venvIn(), '3.12.14'), { error: 'Python executable not found' }); + const toolsVenv = makeVenv(venvIn('tools'), '3.13.15'); + sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([brokenRoot, toolsVenv]); + const manager = await createManager(); + + assert.strictEqual((await manager.get(projectUri))?.envId.id, toolsVenv.envId.id); + }); + + test('still selects a broken root environment when it is the only one', async () => { + const brokenRoot = Object.assign(makeVenv(venvIn(), '3.12.14'), { error: 'Python executable not found' }); + sinon.stub(venvUtils, 'findVirtualEnvironments').resolves([brokenRoot]); + const manager = await createManager(); + + assert.strictEqual((await manager.get(projectUri))?.envId.id, brokenRoot.envId.id); + }); +}); diff --git a/src/test/managers/builtin/venvManager.resolve.unit.test.ts b/src/test/managers/builtin/venvManager.resolve.unit.test.ts new file mode 100644 index 000000000..bac0f1b91 --- /dev/null +++ b/src/test/managers/builtin/venvManager.resolve.unit.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { CancellationTokenSource, LogOutputChannel, Uri } from 'vscode'; +import { + DidChangeEnvironmentsEventArgs, + EnvironmentChangeKind, + EnvironmentManager, + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, +} from '../../../api'; +import { VENV_MANAGER_ID } from '../../../common/constants'; +import * as windowApis from '../../../common/window.apis'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('VenvManager - manual path resolution', () => { + let sandbox: sinon.SinonSandbox; + let cancellation: CancellationTokenSource; + let manager: VenvManager; + let project: PythonProject; + let previous: PythonEnvironment; + let selected: PythonEnvironment; + let resolve: sinon.SinonStub; + let discover: sinon.SinonStub; + + setup(async () => { + sandbox = sinon.createSandbox(); + cancellation = new CancellationTokenSource(); + project = { name: 'project', uri: Uri.file(path.resolve('manual-selection', 'project')) }; + [previous, selected] = ['.venv-2', '.venv-1'].map((name) => + createMockPythonEnvironment({ + name, + envPath: path.join( + project.uri.fsPath, + name, + process.platform === 'win32' ? 'Scripts' : 'bin', + process.platform === 'win32' ? 'python.exe' : 'python', + ), + managerId: VENV_MANAGER_ID, + }), + ); + const api = typemoq.Mock.ofType(); + api.setup((a) => a.getPythonProjects()).returns(() => [project]); + api.setup((a) => a.getPythonProject(typemoq.It.isAny())).returns(() => project); + const baseManager = typemoq.Mock.ofType(); + baseManager.setup((m) => m.getEnvironments('global')).returns(async () => []); + manager = new VenvManager( + typemoq.Mock.ofType().object, + api.object, + baseManager.object, + typemoq.Mock.ofType().object, + ); + sandbox.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task({ report: () => {} }, cancellation.token), + ); + sandbox.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + sandbox.stub(venvUtils, 'getVenvForWorkspace').resolves(previous.environmentPath.fsPath); + sandbox.stub(venvUtils, 'setVenvForWorkspace').resolves(); + discover = sandbox.stub(venvUtils, 'findVirtualEnvironments').callsFake(async () => [previous]); + resolve = sandbox.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(selected); + await manager.initialize(); + }); + + teardown(() => { + sandbox.restore(); + cancellation.dispose(); + }); + + test('publishes a manually resolved environment before selecting it without a discovery refresh', async () => { + assert.strictEqual(await manager.get(project.uri), previous); + const events: DidChangeEnvironmentsEventArgs[] = []; + const subscription = manager.onDidChangeEnvironments((event) => events.push(event)); + try { + const resolved = await manager.resolve(selected.environmentPath); + + assert.strictEqual(resolved, selected); + assert.deepStrictEqual(await manager.getEnvironments('all'), [previous, selected]); + assert.deepStrictEqual(events, [[{ environment: selected, kind: EnvironmentChangeKind.add }]]); + + await manager.set(project.uri, resolved); + + assert.strictEqual(await manager.get(project.uri), selected); + assert.deepStrictEqual(await manager.getEnvironments('all'), [previous, selected]); + sinon.assert.calledOnce(discover); + } finally { + subscription.dispose(); + } + }); + + test('resolving the same interpreter or its environment folder does not duplicate entries or events', async () => { + const events: DidChangeEnvironmentsEventArgs[] = []; + const subscription = manager.onDidChangeEnvironments((event) => events.push(event)); + try { + await manager.resolve(selected.environmentPath); + assert.strictEqual(await manager.resolve(selected.environmentPath), selected); + const folder = Uri.file(path.dirname(path.dirname(selected.environmentPath.fsPath))); + assert.strictEqual(await manager.resolve(folder), selected); + + assert.deepStrictEqual(await manager.getEnvironments('all'), [previous, selected]); + assert.deepStrictEqual(events, [[{ environment: selected, kind: EnvironmentChangeKind.add }]]); + sinon.assert.calledOnce(resolve); + } finally { + subscription.dispose(); + } + }); + + for (const result of ['unresolved', 'non-venv'] as const) { + test(`does not add or publish ${result} interpreter results`, async () => { + resolve.resolves( + result === 'unresolved' + ? undefined + : createMockPythonEnvironment({ + envPath: selected.environmentPath.fsPath, + managerId: 'ms-python.python:system', + }), + ); + const onChanged = sandbox.spy(); + const subscription = manager.onDidChangeEnvironments(onChanged); + try { + assert.strictEqual(await manager.resolve(selected.environmentPath), undefined); + assert.deepStrictEqual(await manager.getEnvironments('all'), [previous]); + sinon.assert.notCalled(onChanged); + } finally { + subscription.dispose(); + } + }); + } +}); diff --git a/src/test/managers/builtin/venvManager.selection.unit.test.ts b/src/test/managers/builtin/venvManager.selection.unit.test.ts new file mode 100644 index 000000000..c7f70c669 --- /dev/null +++ b/src/test/managers/builtin/venvManager.selection.unit.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { CancellationTokenSource, LogOutputChannel, Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + EnvironmentManager, + PythonEnvironmentApi, + PythonProject, +} from '../../../api'; +import { VENV_MANAGER_ID } from '../../../common/constants'; +import * as windowApis from '../../../common/window.apis'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('VenvManager - project selection recovery', () => { + let sandbox: sinon.SinonSandbox; + let cancellation: CancellationTokenSource; + + setup(() => { + sandbox = sinon.createSandbox(); + cancellation = new CancellationTokenSource(); + sandbox.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task({ report: () => {} }, cancellation.token), + ); + sandbox.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + }); + + teardown(() => { + sandbox.restore(); + cancellation.dispose(); + }); + + for (const operation of ['initialize', 'refresh'] as const) { + for (const brokenIndex of [0, 1]) { + test(`${operation} restores other projects when saved selection ${brokenIndex + 1} cannot resolve`, async () => { + const projects: PythonProject[] = ['a', 'b', 'c'].map((name) => ({ + name, + uri: Uri.file(path.resolve('selection-recovery', `project-${name}`)), + })); + const environments = projects.map((project) => + createMockPythonEnvironment({ + name: project.name, + envPath: path.resolve( + 'selection-recovery', + `external-${project.name}`, + process.platform === 'win32' ? 'Scripts' : 'bin', + 'python', + ), + managerId: VENV_MANAGER_ID, + }), + ); + const fallback = createMockPythonEnvironment({ + name: 'global', + envPath: path.resolve('selection-recovery', 'global', 'python'), + }); + const api = typemoq.Mock.ofType(); + api.setup((a) => a.getPythonProjects()).returns(() => projects); + api.setup((a) => a.getPythonProject(typemoq.It.isAny())).returns((uri: Uri) => + projects.find((project) => project.uri.fsPath === uri.fsPath), + ); + const baseManager = typemoq.Mock.ofType(); + baseManager.setup((m) => m.getEnvironments('global')).returns(async () => [fallback]); + const log = typemoq.Mock.ofType(); + const manager = new VenvManager( + typemoq.Mock.ofType().object, + api.object, + baseManager.object, + log.object, + ); + sandbox.stub(venvUtils, 'findVirtualEnvironments').callsFake(async () => []); + sandbox.stub(venvUtils, 'getVenvForWorkspace').callsFake(async (fsPath) => { + const index = projects.findIndex((project) => project.uri.fsPath === fsPath); + return environments[index]?.environmentPath.fsPath; + }); + const resolve = sandbox + .stub(venvUtils, 'resolveVenvPythonEnvironmentPath') + .callsFake(async (fsPath) => environments.find((env) => env.environmentPath.fsPath === fsPath)); + const events: DidChangeEnvironmentEventArgs[] = []; + const subscription = manager.onDidChangeEnvironment((event) => events.push(event)); + try { + if (operation === 'refresh') { + await manager.initialize(); + for (const [index, project] of projects.entries()) { + assert.strictEqual(await manager.get(project.uri), environments[index]); + } + events.length = 0; + } + + const brokenPath = environments[brokenIndex].environmentPath.fsPath; + resolve.withArgs(brokenPath).resolves(undefined); + if (operation === 'initialize') { + await manager.initialize(); + } else { + await manager.refresh(undefined); + } + + for (const [index, project] of projects.entries()) { + if (index !== brokenIndex) { + assert.strictEqual( + (await manager.get(project.uri))?.environmentPath.fsPath, + environments[index].environmentPath.fsPath, + `Project ${project.name} must retain its selected interpreter`, + ); + } + } + assert.deepStrictEqual( + events.map((event) => event.uri?.fsPath), + projects.filter((_, index) => index !== brokenIndex).map((project) => project.uri.fsPath), + 'Healthy projects must still publish their restored selections', + ); + log.verify( + (l) => l.error(`Failed to resolve python environment: ${brokenPath}`), + typemoq.Times.once(), + ); + } finally { + subscription.dispose(); + } + }); + } + } +}); diff --git a/src/test/managers/builtin/venvManager.selectionRace.unit.test.ts b/src/test/managers/builtin/venvManager.selectionRace.unit.test.ts new file mode 100644 index 000000000..355931296 --- /dev/null +++ b/src/test/managers/builtin/venvManager.selectionRace.unit.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { CancellationTokenSource, LogOutputChannel, Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + EnvironmentManager, + PythonEnvironmentApi, + PythonProject, +} from '../../../api'; +import { VENV_MANAGER_ID } from '../../../common/constants'; +import { createDeferred } from '../../../common/utils/deferred'; +import * as windowApis from '../../../common/window.apis'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; + +suite('VenvManager - selection during refresh', () => { + let sandbox: sinon.SinonSandbox; + let cancellation: CancellationTokenSource; + + setup(() => { + sandbox = sinon.createSandbox(); + cancellation = new CancellationTokenSource(); + sandbox.stub(windowApis, 'withProgress').callsFake(async (_options, task) => + task({ report: () => { } }, cancellation.token), + ); + sandbox.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + }); + + teardown(() => { + sandbox.restore(); + cancellation.dispose(); + }); + + for (const batch of [false, true]) { + for (const clear of [false, true]) { + for (const stage of ['discovery', 'global lookup', 'persisted selection', 'resolution', 'later project']) { + test(`preserves ${batch ? 'batch' : 'single'} ${clear ? 'clear' : 'selection'} during ${stage}`, async () => { + const project: PythonProject = { + name: 'project', + uri: Uri.file(path.resolve('selection-race', 'project')), + }; + const peer: PythonProject = { + name: 'peer', + uri: Uri.file(path.resolve('selection-race', 'peer')), + }; + const [oldEnv, selectedEnv, peerEnv] = ['old', 'selected', 'peer-env'].map((name) => + createMockPythonEnvironment({ + name, + envPath: path.resolve('selection-race', name, 'python'), + managerId: VENV_MANAGER_ID, + }), + ); + const resolving = createDeferred(); + const release = createDeferred(); + let pauseAt: string | undefined; + const pause = async () => { + resolving.resolve(); + await release.promise; + }; + sandbox.stub(venvUtils, 'findVirtualEnvironments').callsFake(async () => { + if (pauseAt === 'discovery') { + await pause(); + } + return []; + }); + const api = typemoq.Mock.ofType(); + api.setup((a) => a.getPythonProjects()).returns(() => [project, peer]); + api.setup((a) => a.getPythonProject(typemoq.It.isAny())).returns((uri: Uri) => + [project, peer].find((p) => p.uri.fsPath === uri.fsPath), + ); + const base = typemoq.Mock.ofType(); + base.setup((m) => m.getEnvironments('global')).returns(async () => { + if (pauseAt === 'global lookup') { + await pause(); + } + return []; + }); + const manager = new VenvManager( + typemoq.Mock.ofType().object, + api.object, + base.object, + typemoq.Mock.ofType().object, + ); + const persisted = new Map([ + [project.uri.fsPath, oldEnv.environmentPath.fsPath], + [peer.uri.fsPath, peerEnv.environmentPath.fsPath], + ]); + sandbox.stub(venvUtils, 'getVenvForWorkspace').callsFake(async (scope) => { + const value = persisted.get(scope); + if (pauseAt === 'persisted selection' && scope === project.uri.fsPath) { + await pause(); + } + return value; + }); + sandbox.stub(venvUtils, 'setVenvForWorkspace').callsFake(async (scope, envPath) => { + persisted.set(scope, envPath); + }); + sandbox.stub(venvUtils, 'setVenvForWorkspaces').callsFake(async (scopes, envPath) => { + scopes.forEach((scope) => persisted.set(scope, envPath)); + }); + sandbox.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').callsFake(async (envPath) => { + if ( + (pauseAt === 'resolution' && envPath === oldEnv.environmentPath.fsPath) || + (pauseAt === 'later project' && envPath === peerEnv.environmentPath.fsPath) + ) { + await pause(); + } + return [oldEnv, selectedEnv, peerEnv].find((env) => env.environmentPath.fsPath === envPath); + }); + await manager.initialize(); + assert.strictEqual(await manager.get(project.uri), oldEnv); + assert.strictEqual(await manager.get(peer.uri), peerEnv); + pauseAt = stage; + const events: DidChangeEnvironmentEventArgs[] = []; + const subscription = manager.onDidChangeEnvironment((event) => events.push(event)); + const refresh = manager.refresh(undefined); + try { + await resolving.promise; + const expected = clear ? undefined : selectedEnv; + await manager.set(batch ? [project.uri] : project.uri, expected); + assert.strictEqual(await manager.get(project.uri), expected); + events.length = 0; + release.resolve(); + await refresh; + + assert.strictEqual(persisted.get(project.uri.fsPath), expected?.environmentPath.fsPath); + assert.strictEqual( + (await manager.get(project.uri))?.environmentPath.fsPath, + expected?.environmentPath.fsPath, + 'Completing an older refresh must not restore the previous interpreter', + ); + assert.deepStrictEqual( + events.filter((event) => event.uri?.fsPath === project.uri.fsPath), + [], + 'An older refresh must not publish a stale selection event', + ); + assert.strictEqual(await manager.get(peer.uri), peerEnv, 'Other projects must still be restored'); + pauseAt = undefined; + await manager.refresh(undefined); + assert.strictEqual(await manager.get(project.uri), expected, 'Later refreshes must still restore selections'); + } finally { + release.resolve(); + await refresh; + subscription.dispose(); + } + }); + } + } + } +}); diff --git a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts index 1f7061050..c665d140b 100644 --- a/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.createWithProgress.unit.test.ts @@ -6,13 +6,15 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { CancellationError, LogOutputChannel, Uri } from 'vscode'; +import { CancellationError, CancellationTokenSource, LogOutputChannel, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; import * as windowApis from '../../../common/window.apis'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import * as builtinHelpers from '../../../managers/builtin/helpers'; import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; -import { createWithProgress, getBaseInterpreterForVenv } from '../../../managers/builtin/venvUtils'; +import { createWithProgress, getBaseInterpreterForVenv, quickCreateVenv } from '../../../managers/builtin/venvUtils'; +import * as pipUtils from '../../../managers/builtin/pipUtils'; +import { PythonToolError, PythonToolOperation } from '../../../internal/pythonToolSupport'; import { NativePythonEnvironmentKind, NativePythonFinder } from '../../../managers/common/nativePythonFinder'; import * as managerUtils from '../../../managers/common/utils'; @@ -136,6 +138,119 @@ suite('createWithProgress uv tracking', () => { assert.strictEqual(typeof result.pkgInstallationErr, 'string'); assert.strictEqual(result.pkgInstallationCancelled, true); }); + + test('private creation forwards cancellation and package work without opening progress UI', async () => { + const source = new CancellationTokenSource(); + const manage = sinon.stub().resolves(); + const operation: PythonToolOperation = { + token: source.token, + managePackages: manage, + getGlobalEnvironments: async () => [baseEnvironment], + }; + try { + const result = await createWithProgress( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + envPath, + { install: ['requests'], uninstall: [] }, + false, + { operation }, + ); + assert.ok(result?.environment); + assert.strictEqual((builtinHelpers.runUV as sinon.SinonStub).firstCall.args[3], source.token); + assert.strictEqual( + (builtinHelpers.runUV as sinon.SinonStub).firstCall.args[5], + path.join(tempRoot, '.pyprojectx', 'main', 'uv'), + ); + assert.strictEqual((builtinHelpers.runUV as sinon.SinonStub).firstCall.args[6], true); + sinon.assert.calledWith( + builtinHelpers.shouldUseUv as sinon.SinonStub, + log, + baseEnvironment.environmentPath.fsPath, + Uri.file(tempRoot).fsPath, + source.token, + ); + assert.ok(manage.calledOnce); + assert.strictEqual(manage.firstCall.args[1].runHeadless, true); + assert.ok((api.managePackages as sinon.SinonStub).notCalled); + assert.ok((windowApis.withProgress as sinon.SinonStub).notCalled); + } finally { + source.dispose(); + } + }); + + test('private package failure preserves the created environment rather than returning success', async () => { + const source = new CancellationTokenSource(); + const operation: PythonToolOperation = { + token: source.token, + managePackages: async () => { + throw new Error('dependency failure'); + }, + getGlobalEnvironments: async () => [baseEnvironment], + }; + try { + await assert.rejects( + createWithProgress( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + envPath, + { install: ['requests'], uninstall: [] }, + false, + { operation }, + ), + (error: unknown) => + error instanceof PythonToolError && error.code === 'PACKAGE_INSTALL_FAILED' && !!error.environment, + ); + } finally { + source.dispose(); + } + }); + + test('private quick create rejects pyproject validation errors without Continue Anyway', async () => { + const source = new CancellationTokenSource(); + const operation: PythonToolOperation = { + token: source.token, + managePackages: sinon.stub().resolves(), + getGlobalEnvironments: async () => [baseEnvironment], + }; + api.getPythonProject = sinon.stub().returns({ name: 'project', uri: Uri.file(tempRoot) }); + sinon.stub(pipUtils, 'getProjectInstallable').resolves({ + installables: [], + validationError: { + message: 'Invalid build-system', + fileUri: Uri.file(path.join(tempRoot, 'pyproject.toml')), + }, + }); + const question = sinon + .stub(pipUtils, 'shouldProceedAfterPyprojectValidation') + .throws(new Error('Unexpected question')); + try { + await assert.rejects( + quickCreateVenv( + nativeFinder, + api, + log, + manager, + baseEnvironment, + Uri.file(tempRoot), + undefined, + operation, + ), + (error: unknown) => error instanceof PythonToolError && error.code === 'INVALID_PROJECT', + ); + assert.ok(question.notCalled); + } finally { + source.dispose(); + } + }); }); suite('getBaseInterpreterForVenv', () => { diff --git a/src/test/managers/conda/commands.unit.test.ts b/src/test/managers/conda/commands.unit.test.ts index af944fe1b..0d3c908f1 100644 --- a/src/test/managers/conda/commands.unit.test.ts +++ b/src/test/managers/conda/commands.unit.test.ts @@ -14,6 +14,7 @@ suite('Conda commands', () => { let environmentPath: string; let mockLog: LogOutputChannel; let runCondaStub: sinon.SinonStub; + let runConfiguredCondaStub: sinon.SinonStub; setup(() => { environmentPath = Uri.file('environment').fsPath; @@ -22,6 +23,7 @@ suite('Conda commands', () => { get: () => undefined, } as unknown as ReturnType); runCondaStub = sinon.stub(condaUtils, 'runCondaExecutable').resolves(''); + runConfiguredCondaStub = sinon.stub(condaUtils, 'runConda').resolves(''); }); teardown(() => { @@ -142,12 +144,12 @@ suite('Conda commands', () => { }); test('CondaVersionCommand parses the version', async () => { - runCondaStub.resolves('conda 24.1.2'); + runConfiguredCondaStub.resolves('conda 24.1.2'); const command = new CondaVersionCommand({ pythonExecutable: 'conda', log: mockLog }); const result = await command.execute(); - assert.deepStrictEqual(runCondaStub.firstCall.args[0], ['--version']); + assert.deepStrictEqual(runConfiguredCondaStub.firstCall.args[0], ['--version']); assert.strictEqual(result?.public, '24.1.2'); }); }); diff --git a/src/test/managers/conda/condaEnvManager.projectIsolation.unit.test.ts b/src/test/managers/conda/condaEnvManager.projectIsolation.unit.test.ts new file mode 100644 index 000000000..57ae121bd --- /dev/null +++ b/src/test/managers/conda/condaEnvManager.projectIsolation.unit.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as typemoq from 'typemoq'; +import { CancellationTokenSource, LogOutputChannel, Uri, WorkspaceConfiguration } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../../api'; +import * as telemetry from '../../../common/telemetry/sender'; +import { isSameOrParentPath } from '../../../common/utils/pathUtils'; +import * as windowApis from '../../../common/window.apis'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PythonProjectManagerImpl } from '../../../features/projectManager'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { CondaEnvManager } from '../../../managers/conda/condaEnvManager'; +import * as sourcing from '../../../managers/conda/condaSourcingUtils'; +import * as condaUtils from '../../../managers/conda/condaUtils'; +import { makeMockCondaEnvironment } from '../../mocks/pythonEnvironment'; + +interface IsolationScenario { + name: string; + hasBase: boolean; + otherLocal?: boolean; + explicit?: 'shared' | 'external'; + nesting?: 'owner-is-child' | 'other-is-child'; + reverseProjectOrder?: boolean; +} + +const scenarios: IsolationScenario[] = [ + { name: 'no-base', hasBase: false }, + { name: 'base', hasBase: true, reverseProjectOrder: true }, + { name: 'two-local', hasBase: true, otherLocal: true }, + { name: 'explicit-shared', hasBase: true, explicit: 'shared' }, + { name: 'explicit-external', hasBase: true, explicit: 'external', reverseProjectOrder: true }, + { name: 'nested-parent', hasBase: true, nesting: 'owner-is-child' }, + { name: 'nested-child', hasBase: true, nesting: 'other-is-child' }, +]; + +suite('CondaEnvManager - project isolation', () => { + let sandbox: sinon.SinonSandbox; + let cancellation: CancellationTokenSource; + + function assertEnvironment( + actual: PythonEnvironment | undefined, + expected: PythonEnvironment | undefined, + message?: string, + ): void { + assert.strictEqual(actual?.envId.id, expected?.envId.id, message); + } + + setup(() => { + sandbox = sinon.createSandbox(); + cancellation = new CancellationTokenSource(); + const condaPath = path.resolve('conda-isolation', 'conda'); + sandbox.stub(condaUtils, 'getConda').resolves(condaPath); + sandbox.stub(condaUtils, 'getCondaPathSetting').returns(undefined); + sandbox.stub(condaUtils, 'getCondaForGlobal').resolves(undefined); + sandbox + .stub(sourcing, 'constructCondaSourcingStatus') + .resolves(new sourcing.CondaSourcingStatus(condaPath, path.dirname(condaPath))); + sandbox.stub(telemetry, 'sendTelemetryEvent'); + sandbox + .stub(windowApis, 'withProgress') + .callsFake(async (_options, task) => task({ report: () => {} }, cancellation.token)); + }); + + teardown(() => { + sandbox.restore(); + cancellation.dispose(); + }); + + for (const operation of ['initialize', 'refresh'] as const) { + for (const scenario of scenarios) { + const projectOrders = scenario.reverseProjectOrder ? [false, true] : [false]; + for (const ownerFirst of projectOrders) { + test(`${operation}: project selections remain isolated (${scenario.name}, ownerFirst=${ownerFirst})`, async () => { + const owner: PythonProject = { + name: 'owner', + uri: Uri.file( + scenario.nesting === 'owner-is-child' + ? path.resolve('conda-isolation', 'project-a', 'nested') + : path.resolve('conda-isolation', 'project-a'), + ), + }; + const other: PythonProject = { + name: 'other', + uri: Uri.file( + scenario.nesting === 'owner-is-child' + ? path.resolve('conda-isolation', 'project-a') + : scenario.nesting === 'other-is-child' + ? path.resolve('conda-isolation', 'project-a', 'nested') + : path.resolve('conda-isolation', 'project-b'), + ), + }; + const projects = ownerFirst ? [owner, other] : [other, owner]; + const projectsBySpecificity = [...projects].sort( + (a, b) => b.uri.fsPath.length - a.uri.fsPath.length, + ); + const local = makeMockCondaEnvironment('project-env', Uri.joinPath(owner.uri, '.conda').fsPath); + const base = makeMockCondaEnvironment('base', path.resolve('conda-isolation', 'base')); + const otherLocal = makeMockCondaEnvironment( + 'other-project-env', + Uri.joinPath(other.uri, '.conda').fsPath, + ); + const external = makeMockCondaEnvironment('external', path.resolve('conda-isolation', 'external')); + const api = typemoq.Mock.ofType(); + api.setup((a) => a.getPythonProjects()).returns(() => projects); + api.setup((a) => a.getPythonProject(typemoq.It.isAny())).returns((uri: Uri) => + projectsBySpecificity.find((project) => isSameOrParentPath(project.uri.fsPath, uri.fsPath)), + ); + const refresh = sandbox + .stub(condaUtils, 'refreshCondaEnvs') + .resolves(scenario.hasBase ? [base] : []); + const savedSelection = sandbox.stub(condaUtils, 'getCondaForWorkspace').resolves(undefined); + const resolve = sandbox.stub(condaUtils, 'resolveCondaPath').resolves(external); + const manager = new CondaEnvManager( + typemoq.Mock.ofType().object, + api.object, + typemoq.Mock.ofType().object, + ); + try { + if (operation === 'refresh') { + await manager.initialize(); + assertEnvironment(await manager.get(other.uri), scenario.hasBase ? base : undefined); + } + refresh.resolves([ + ...(scenario.hasBase ? [base] : []), + local, + ...(scenario.otherLocal ? [otherLocal] : []), + ]); + const explicit = + scenario.explicit === 'shared' + ? local + : scenario.explicit === 'external' + ? external + : undefined; + savedSelection.withArgs(other.uri.fsPath).resolves(explicit?.environmentPath.fsPath); + const expectedOther = + explicit ?? (scenario.otherLocal ? otherLocal : scenario.hasBase ? base : undefined); + if (operation === 'initialize') { + await manager.initialize(); + } else { + await manager.refresh(undefined); + } + + assertEnvironment(await manager.get(owner.uri), local); + assertEnvironment( + await manager.get(other.uri), + expectedOther, + "Only an explicit selection may make a project use another project's local environment", + ); + assertEnvironment( + await manager.get(Uri.joinPath(other.uri, 'main.py')), + expectedOther, + "Files in the other project must use that project's selected environment or fallback", + ); + if (scenario.explicit === 'external') { + sinon.assert.calledOnce(resolve); + assert.strictEqual(resolve.firstCall.args[0], external.environmentPath.fsPath); + } else { + sinon.assert.notCalled(resolve); + } + } finally { + manager.dispose(); + } + }); + } + } + } + + test('uses the real project manager to isolate nested project environments', async () => { + sandbox.stub(workspaceApis, 'getConfiguration').returns({ + get: (_key: string, defaultValue?: T) => defaultValue, + } as WorkspaceConfiguration); + sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([]); + + const projects = new PythonProjectManagerImpl(); + const parent = projects.create('parent', Uri.file(path.resolve('conda-isolation', 'project-a'))); + const child = projects.create('child', Uri.joinPath(parent.uri, 'nested')); + await projects.add([parent, child], { persistSettings: false }); + + const base = makeMockCondaEnvironment('base', path.resolve('conda-isolation', 'base')); + const childLocal = makeMockCondaEnvironment('child-env', Uri.joinPath(child.uri, '.conda').fsPath); + sandbox.stub(condaUtils, 'refreshCondaEnvs').resolves([base, childLocal]); + sandbox.stub(condaUtils, 'getCondaForWorkspace').resolves(undefined); + const api = { + getPythonProjects: () => projects.getProjects(), + getPythonProject: (uri: Uri) => projects.get(uri), + } as unknown as PythonEnvironmentApi; + const manager = new CondaEnvManager( + typemoq.Mock.ofType().object, + api, + typemoq.Mock.ofType().object, + ); + + try { + await manager.initialize(); + + assertEnvironment(await manager.get(parent.uri), base); + assertEnvironment(await manager.get(Uri.joinPath(parent.uri, 'main.py')), base); + assertEnvironment(await manager.get(child.uri), childLocal); + assertEnvironment(await manager.get(Uri.joinPath(child.uri, 'main.py')), childLocal); + } finally { + manager.dispose(); + projects.dispose(); + } + }); +}); diff --git a/src/test/managers/conda/condaUtils.toolExecution.unit.test.ts b/src/test/managers/conda/condaUtils.toolExecution.unit.test.ts new file mode 100644 index 000000000..7eaddc6d9 --- /dev/null +++ b/src/test/managers/conda/condaUtils.toolExecution.unit.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { WorkspaceConfiguration } from 'vscode'; +import * as childProcesses from '../../../common/childProcess.apis'; +import * as platformUtils from '../../../common/utils/platformUtils'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { PythonToolError } from '../../../internal/pythonToolSupport'; +import { + clearCondaCache, + getCondaForTools, + runCondaExecutable, +} from '../../../managers/conda/condaUtils'; +import { MockChildProcess } from '../../mocks/mockChildProcess'; + +suite('Conda tool execution', () => { + let temp: string; + let condaSetting: string; + + setup(async () => { + temp = await fs.mkdtemp(path.join(os.tmpdir(), 'conda-tools-')); + condaSetting = path.join(temp, process.platform === 'win32' ? 'conda.exe' : 'conda'); + await fs.outputFile(condaSetting, ''); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string): T | undefined => (key === 'condaPath' ? (condaSetting as T) : undefined), + } as WorkspaceConfiguration); + await clearCondaCache(); + }); + + teardown(async () => { + sinon.restore(); + await clearCondaCache(); + await fs.remove(temp); + }); + + test('spawns the executable directly and preserves package metacharacters as one argument', async () => { + const child = new MockChildProcess(condaSetting, []); + const spawn: sinon.SinonStub = sinon.stub(childProcesses, 'spawnProcess'); + spawn.callsFake(() => { + setImmediate(() => { + child.stdout?.emit('data', Buffer.from('complete')); + child.emit('close', 0, null); + }); + return child; + }); + const args = ['install', '--yes', 'package" & echo injected & rem "']; + + const output = await runCondaExecutable(args, undefined, undefined, undefined, true); + + assert.strictEqual(output, 'complete'); + assert.strictEqual(spawn.firstCall.args[0], path.resolve(condaSetting)); + assert.deepStrictEqual(spawn.firstCall.args[1], args); + const options = spawn.firstCall.args[2]; + assert.ok(options); + assert.strictEqual(options.shell, false); + }); + + test('resolves a Windows command script to the environment conda executable', async () => { + sinon.stub(platformUtils, 'isWindows').returns(true); + const root = path.join(temp, 'miniconda'); + condaSetting = path.join(root, 'condabin', 'conda.bat'); + const executable = path.join(root, 'Scripts', 'conda.exe'); + await fs.outputFile(condaSetting, ''); + await fs.outputFile(executable, ''); + + const result = await getCondaForTools(); + + assert.strictEqual(result, path.resolve(executable)); + }); + + test('rejects a Windows command script when no safe executable launcher exists', async () => { + sinon.stub(platformUtils, 'isWindows').returns(true); + condaSetting = path.join(temp, 'condabin', 'conda.bat'); + await fs.outputFile(condaSetting, ''); + + await assert.rejects( + getCondaForTools(), + (error: unknown) => + error instanceof PythonToolError && + error.code === 'CONDA_EXECUTABLE_NOT_FOUND' && + error.message.includes('python.condaPath'), + ); + }); +}); diff --git a/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts b/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts index 1ffa934e4..9346e1cb3 100644 --- a/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts +++ b/src/test/managers/pipenv/pipenvUtils.getPipenv.unit.test.ts @@ -1,9 +1,15 @@ import assert from 'assert'; import * as sinon from 'sinon'; +import * as childProcessApis from '../../../common/childProcess.apis'; import * as logging from '../../../common/logging'; import * as persistentState from '../../../common/persistentState'; import * as settingHelpers from '../../../features/settings/settingHelpers'; -import { clearPipenvCache, getPipenv, PIPENV_PATH_KEY } from '../../../managers/pipenv/pipenvUtils'; +import { + clearPipenvCache, + getPipenv, + getPipenvVersion, + PIPENV_PATH_KEY, +} from '../../../managers/pipenv/pipenvUtils'; /** * Tests for getPipenv prioritization. @@ -110,3 +116,26 @@ suite('Pipenv Utils - getPipenv prioritization', () => { return promise; }); }); + +suite('Pipenv Utils - getPipenvVersion', () => { + teardown(() => { + sinon.restore(); + }); + + test('parses the Pipenv version', async () => { + const execProcess = sinon + .stub(childProcessApis, 'execProcess') + .resolves({ stdout: 'pipenv, version 2024.4.1\n', stderr: '' }); + + const version = await getPipenvVersion('pipenv'); + + assert.strictEqual(version, '2024.4.1'); + assert.ok(execProcess.calledWith('"pipenv" --version')); + }); + + test('returns undefined when the command fails', async () => { + sinon.stub(childProcessApis, 'execProcess').rejects(new Error('Command not found')); + + assert.strictEqual(await getPipenvVersion('pipenv'), undefined); + }); +}); diff --git a/src/test/managers/pyenv/pyenvUtils.unit.test.ts b/src/test/managers/pyenv/pyenvUtils.unit.test.ts index 0f7024d7c..0b856997c 100644 --- a/src/test/managers/pyenv/pyenvUtils.unit.test.ts +++ b/src/test/managers/pyenv/pyenvUtils.unit.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert'; import * as path from 'path'; import * as sinon from 'sinon'; -import { getPyenvDir } from '../../../managers/pyenv/pyenvUtils'; +import * as childProcessApis from '../../../common/childProcess.apis'; +import { getPyenvDir, getPyenvVersion } from '../../../managers/pyenv/pyenvUtils'; suite('pyenvUtils - getPyenvDir', () => { let originalPyenvRoot: string | undefined; @@ -42,3 +43,40 @@ suite('pyenvUtils - getPyenvDir', () => { assert.strictEqual(result, path.join('C:', 'Users', 'user', '.pyenv', 'pyenv-win')); }); }); + +suite('pyenvUtils - getPyenvVersion', () => { + teardown(() => { + sinon.restore(); + }); + + test('parses the Pyenv version', async () => { + const execProcess = sinon + .stub(childProcessApis, 'execProcess') + .resolves({ stdout: 'pyenv 2.5.3\n', stderr: '' }); + + const version = await getPyenvVersion('pyenv'); + + assert.strictEqual(version, '2.5.3'); + assert.ok(execProcess.calledWith('"pyenv" --version')); + }); + + test('parses the Pyenv for Windows version', async () => { + sinon.stub(childProcessApis, 'execProcess').resolves({ stdout: 'pyenv-win 3.1.1\n', stderr: '' }); + + assert.strictEqual(await getPyenvVersion('pyenv.bat'), '3.1.1'); + }); + + test('returns the PEP 440 release from a git-describe version', async () => { + sinon + .stub(childProcessApis, 'execProcess') + .resolves({ stdout: 'pyenv 2.3.24-19-gabcdef1\n', stderr: '' }); + + assert.strictEqual(await getPyenvVersion('pyenv'), '2.3.24'); + }); + + test('returns undefined when the command fails', async () => { + sinon.stub(childProcessApis, 'execProcess').rejects(new Error('Command not found')); + + assert.strictEqual(await getPyenvVersion('pyenv'), undefined); + }); +}); diff --git a/src/test/mocks/vsc/index.ts b/src/test/mocks/vsc/index.ts index 39e96b095..6f98ce470 100644 --- a/src/test/mocks/vsc/index.ts +++ b/src/test/mocks/vsc/index.ts @@ -153,7 +153,7 @@ export class EventEmitter implements vscode.EventEmitter { } export class CancellationToken extends EventEmitter implements vscode.CancellationToken { - public isCancellationRequested!: boolean; + public isCancellationRequested = false; public onCancellationRequested: vscode.Event; diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index 3bb62ccde..c7205c19b 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -10,6 +10,25 @@ import type { Disposable, Event } from 'vscode'; +const DIALOG_SERVICE_TEST_ERROR = 'DialogService: refused to show dialog in tests'; + +/** + * Runs an interactive test operation, treating VS Code's blocked test dialog as user cancellation. + * + * @param operation The operation that may attempt to show a dialog. + * @returns The operation result, or undefined when the test host blocks a dialog. + */ +export async function runWithBlockedDialogsAsCancellation(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (String(error).includes(DIALOG_SERVICE_TEST_ERROR)) { + return undefined; + } + throw error; + } +} + /** * Sleep for a specified number of milliseconds. * Use sparingly - prefer waitForCondition() for most cases. diff --git a/src/types.ts b/src/types.ts index 8699ce618..f3cfd72ae 100644 --- a/src/types.ts +++ b/src/types.ts @@ -411,6 +411,12 @@ export interface EnvironmentManager { */ readonly log?: LogOutputChannel; + /** + * Returns the version of the underlying environment management tool. + * @returns A promise that resolves to a {@link Pep440Version} object, or `undefined` if not available. + */ + getVersion?(): Promise; + /** * The quick create details for the environment manager. Having this method also enables the quick create feature * for the environment manager. Should Implement {@link EnvironmentManager.create} to support quick create.