From d20d357ecc98cc094430e3f4d5178c7d4607e5ac Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 13:17:16 -0800 Subject: [PATCH 01/10] feat: add search feature to env manager UI --- README.md | 108 +++--- docs/search-paths-and-glob-patterns.md | 354 ++++++++++++++++++ package.json | 13 +- package.nls.json | 1 + src/common/localize.ts | 10 + src/common/utils/pathUtils.ts | 18 + src/extension.ts | 7 + src/features/views/envManagerSearch.ts | 138 +++++++ src/managers/common/nativePythonFinder.ts | 40 +- src/test/common/pathUtils.unit.test.ts | 86 ++++- .../views/envManagerSearch.unit.test.ts | 65 ++++ ...Finder.getAllExtraSearchPaths.unit.test.ts | 43 +-- 12 files changed, 779 insertions(+), 104 deletions(-) create mode 100644 docs/search-paths-and-glob-patterns.md create mode 100644 src/features/views/envManagerSearch.ts create mode 100644 src/test/features/views/envManagerSearch.unit.test.ts diff --git a/README.md b/README.md index 2782b6ac..fbc9a77c 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ The Python Environments extension for VS Code helps you manage Python environments and packages using your preferred environment manager, backed by its extensible APIs. This extension provides unique support for specifying environments for specific files, entire Python folders, or projects, including multi-root and mono-repo scenarios. The core feature set includes: -- 🌐 Create, delete, and manage environments -- πŸ“¦ Install and uninstall packages within the selected environment -- βœ… Create activated terminals -- πŸ–ŒοΈ Add and create new Python projects +- 🌐 Create, delete, and manage environments +- πŸ“¦ Install and uninstall packages within the selected environment +- βœ… Create activated terminals +- πŸ–ŒοΈ Add and create new Python projects > **Note:** This extension is in preview, and its APIs and features are subject to change as the project evolves. @@ -31,9 +31,9 @@ The Python Environments panel provides an interface to create, delete and manage To simplify the environment creation process, you can use "Quick Create" to automatically create a new virtual environment using: -- Your default environment manager (e.g., `venv`) -- The latest Python version -- Workspace dependencies +- Your default environment manager (e.g., `venv`) +- The latest Python version +- Workspace dependencies For more control, you can create a custom environment where you can specify Python version, environment name, packages to be installed, and more! @@ -61,9 +61,9 @@ The following environment managers are supported out of the box: **Legend:** -- **Create**: Ability to create new environments interactively. -- **Quick Create**: Ability to create environments with minimal user input. -- **Find Environments**: Ability to discover and list existing environments. +- **Create**: Ability to create new environments interactively. +- **Quick Create**: Ability to create environments with minimal user input. +- **Find Environments**: Ability to discover and list existing environments. Environment managers are responsible for specifying which package manager will be used by default to install and manage Python packages within the environment (`venv` uses `pip` by default). This ensures that packages are managed consistently according to the preferred tools and settings of the chosen environment manager. @@ -107,8 +107,8 @@ There are a few ways to add a Python Project from the Python Environments panel: The **Python Envs: Create New Project from Template** command simplifies the process of starting a new Python project by scaffolding it for you. Whether in a new workspace or an existing one, this command configures the environment and boilerplate file structure, so you don’t have to worry about the initial setup, and only the code you want to write. There are currently two project types supported: -- Package: A structured Python package with files like `__init__.py` and setup configurations. -- Script: A simple project for standalone Python scripts, ideal for quick tasks or just to get you started. +- Package: A structured Python package with files like `__init__.py` and setup configurations. +- Script: A simple project for standalone Python scripts, ideal for quick tasks or just to get you started. ## Command Reference @@ -127,31 +127,31 @@ All commands can be accessed via the Command Palette (`ctrl/cmd + Shift + P`): ### Python Environments Settings (`python-envs.`) -| Setting (python-envs.) | Default | Description | -| ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| defaultEnvManager | `"ms-python.python:venv"` | The default environment manager used for creating and managing environments. | -| defaultPackageManager | `"ms-python.python:pip"` | The default package manager to use for installing and managing packages. This is often dictated by the default environment manager but can be customized. | -| 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. | -| 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. | +| Setting (python-envs.) | Default | Description | +| --------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| defaultEnvManager | `"ms-python.python:venv"` | The default environment manager used for creating and managing environments. | +| defaultPackageManager | `"ms-python.python:pip"` | The default package manager to use for installing and managing packages. This is often dictated by the default environment manager but can be customized. | +| 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. | +| globalSearchPaths | `[]` | Global search paths for Python environments. Array of absolute directory paths or glob patterns to search for environments at the user level. This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. Supports glob patterns like `**/.venv` or `/path/*/envs`. See [Search Paths and Glob Patterns](docs/search-paths-and-glob-patterns.md) for detailed usage. | +| workspaceSearchPaths | `[]` | Workspace search paths for Python environments. Can be absolute paths, relative directory paths, or glob patterns searched within the workspace. Supports glob patterns like `**/.venv` or `tests/*/venv`. See [Search Paths and Glob Patterns](docs/search-paths-and-glob-patterns.md) for detailed usage. | ### Supported Legacy Python Settings (`python.`) The following settings from the Python extension (`python.*`) are also supported by Python Environments. -| Setting (`python.`) | Default | Description | -| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| condaPath | `""` | Path to the conda executable. Used to locate and run conda for environment discovery and management. | -| defaultInterpreterPath | `"python"` | Path to the default Python interpreter. | -| envFile | `"${workspaceFolder}/.env"` | Path to the environment file (`.env`) containing environment variable definitions. Used with `python.terminal.useEnvFile` to inject environment variables into terminals. | -| terminal.activateEnvironment | `true` | Legacy setting for terminal auto-activation. If `python-envs.terminal.autoActivationType` is not set and this is `false`, terminal auto-activation will be disabled. **Superseded by** `python-envs.terminal.autoActivationType` which takes precedence when configured. | -| terminal.executeInFileDir | `false` | When `true`, the terminal's working directory will be set to the directory containing the Python file being executed, rather than the project root directory. | -| terminal.useEnvFile | `false` | Controls whether environment variables from `.env` files (specified by `python.envFile`) are injected into terminals. | -| venvFolders | `[]` | Array of folder names to search for virtual environments. These folders are searched in addition to the standard locations. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | -| venvPath | `""` | Path to a folder containing virtual environments. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | +| Setting (`python.`) | Default | Description | +| ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| condaPath | `""` | Path to the conda executable. Used to locate and run conda for environment discovery and management. | +| defaultInterpreterPath | `"python"` | Path to the default Python interpreter. | +| envFile | `"${workspaceFolder}/.env"` | Path to the environment file (`.env`) containing environment variable definitions. Used with `python.terminal.useEnvFile` to inject environment variables into terminals. | +| terminal.activateEnvironment | `true` | Legacy setting for terminal auto-activation. If `python-envs.terminal.autoActivationType` is not set and this is `false`, terminal auto-activation will be disabled. **Superseded by** `python-envs.terminal.autoActivationType` which takes precedence when configured. | +| terminal.executeInFileDir | `false` | When `true`, the terminal's working directory will be set to the directory containing the Python file being executed, rather than the project root directory. | +| terminal.useEnvFile | `false` | Controls whether environment variables from `.env` files (specified by `python.envFile`) are injected into terminals. | +| venvFolders | `[]` | Array of folder names to search for virtual environments. These folders are searched in addition to the standard locations. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | +| venvPath | `""` | Path to a folder containing virtual environments. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | ## Extensibility @@ -207,13 +207,13 @@ usage: `await vscode.commands.executeCommand('python-envs.createAny', options);` The Python Environments extension supports shell startup activation for environments. This feature allows you to automatically activate a Python environment when you open a terminal in VS Code. The activation is done by modifying the shell's startup script, which is supported for the following shells: -- **Bash**: `~/.bashrc` -- **Zsh**: `~/.zshrc` (or `$ZDOTDIR/.zshrc` if `ZDOTDIR` is set) -- **Fish**: `~/.config/fish/config.fish` -- **PowerShell**: - - (Mac/Linux):`~/.config/powershell/profile.ps1` - - (Windows): `~\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` -- **CMD**: `~/.cmdrc/cmd_startup.bat` +- **Bash**: `~/.bashrc` +- **Zsh**: `~/.zshrc` (or `$ZDOTDIR/.zshrc` if `ZDOTDIR` is set) +- **Fish**: `~/.config/fish/config.fish` +- **PowerShell**: + - (Mac/Linux):`~/.config/powershell/profile.ps1` + - (Windows): `~\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` +- **CMD**: `~/.cmdrc/cmd_startup.bat` If at any time you would like to revert the changes made to the shell's script, you can do so by running `Python Envs: Revert Shell Startup Script Changes` via the Command Palette. @@ -310,11 +310,11 @@ This section provides an overview of how the Python extension interacts with the Tools that may rely on these APIs in their own extensions include: -- **Debuggers** (e.g., `debugpy`) -- **Linters** (e.g., Pylint, Flake8, Mypy) -- **Formatters** (e.g., Black, autopep8) -- **Language Server extensions** (e.g., Pylance, Jedi) -- **Environment and Package Manager extensions** (e.g., Pixi, Conda, Hatch) +- **Debuggers** (e.g., `debugpy`) +- **Linters** (e.g., Pylint, Flake8, Mypy) +- **Formatters** (e.g., Black, autopep8) +- **Language Server extensions** (e.g., Pylance, Jedi) +- **Environment and Package Manager extensions** (e.g., Pixi, Conda, Hatch) ### API Dependency @@ -334,6 +334,12 @@ The relationship is illustrated below: In **trusted mode**, the Python Environments extension supports tasks like managing environments, installing/removing packages, and running tools. In **untrusted mode**, functionality is limited to language features, ensuring a secure and restricted environment. +## Documentation + +- [Managing Python Projects](docs/managing-python-projects.md): Learn how to create and manage Python projects +- [Search Paths and Glob Patterns](docs/search-paths-and-glob-patterns.md): Customize where the extension searches for Python environments +- [Projects API Reference](docs/projects-api-reference.md): Technical reference for extension developers + ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a @@ -350,13 +356,13 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio ## Questions, issues, feature requests, and contributions -- If you have a question about how to accomplish something with the extension, please [ask on our Discussions page](https://github.com/microsoft/vscode-python/discussions/categories/q-a). -- If you come across a problem with the extension, please [file an issue](https://github.com/microsoft/vscode-python). -- Contributions are always welcome! Please see our [contributing guide](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md) for more details. -- Any and all feedback is appreciated and welcome! - - If someone has already [filed an issue](https://github.com/Microsoft/vscode-python) that encompasses your feedback, please leave a πŸ‘/πŸ‘Ž reaction on the issue. - - Otherwise please start a [new discussion](https://github.com/microsoft/vscode-python/discussions/categories/ideas). -- If you're interested in the development of the extension, you can read about our [development process](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md#development-process). +- If you have a question about how to accomplish something with the extension, please [ask on our Discussions page](https://github.com/microsoft/vscode-python/discussions/categories/q-a). +- If you come across a problem with the extension, please [file an issue](https://github.com/microsoft/vscode-python). +- Contributions are always welcome! Please see our [contributing guide](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md) for more details. +- Any and all feedback is appreciated and welcome! + - If someone has already [filed an issue](https://github.com/Microsoft/vscode-python) that encompasses your feedback, please leave a πŸ‘/πŸ‘Ž reaction on the issue. + - Otherwise please start a [new discussion](https://github.com/microsoft/vscode-python/discussions/categories/ideas). +- If you're interested in the development of the extension, you can read about our [development process](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md#development-process). ## Data and telemetry diff --git a/docs/search-paths-and-glob-patterns.md b/docs/search-paths-and-glob-patterns.md new file mode 100644 index 00000000..d6702bd9 --- /dev/null +++ b/docs/search-paths-and-glob-patterns.md @@ -0,0 +1,354 @@ +# Search Paths and Glob Patterns + +This guide explains how to configure where the Python Environments extension searches for Python environments using search paths and glob patterns. By the end, you'll understand how to effectively customize environment discovery to match your development workflow. + +## Overview + +By default, the Python Environments extension automatically discovers environments in well-known locations like the workspace folders, common virtual environment directories, and system Python installations. However, you can customize where the extension searches using two settings: + +- **`python-envs.globalSearchPaths`**: Global search paths applied to all workspaces +- **`python-envs.workspaceSearchPaths`**: Search paths specific to the current workspace + +Both settings support **glob patterns**, which allow you to specify flexible search patterns that match multiple directories. + +## When to Use Custom Search Paths + +Consider configuring custom search paths when: + +| Scenario | Example | +| ------------------------------- | -------------------------------------------------------------- | +| Centralized environment storage | All environments stored in `~/python-envs/` | +| Mono-repo structure | Multiple projects with nested `.venv` folders | +| Non-standard locations | Environments in `/opt/`, network drives, or custom directories | +| Team conventions | Standardized environment naming patterns | +| Testing scenarios | Temporary environments in test directories | + +## Configuring Search Paths + +### Global search paths + +Global search paths apply across all your VS Code workspaces. Use these for environment locations that are consistent across projects. + +1. Open Settings (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux). +2. Search for `python-envs.globalSearchPaths`. +3. Click **Add Item** to add a new path. +4. Enter an absolute path or glob pattern. + +Example configuration: + +```json +{ + "python-envs.globalSearchPaths": [ + "/Users/username/python-envs", + "/Users/username/projects/*/venv", + "/opt/python-environments/**" + ] +} +``` + +### Workspace search paths + +Workspace search paths apply only to the current workspace. Use these for project-specific environment locations. + +1. Open Settings (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux). +2. Switch to **Workspace** scope (not User). +3. Search for `python-envs.workspaceSearchPaths`. +4. Click **Add Item** to add a new path. +5. Enter a relative path (from workspace root) or absolute path. + +Example configuration: + +```json +{ + "python-envs.workspaceSearchPaths": [".venv", "tests/**/.venv", "services/*/env"] +} +``` + +> **Note**: Relative paths in `workspaceSearchPaths` are resolved from the workspace root directory. + +## Glob Pattern Syntax + +Glob patterns provide a flexible way to match multiple directories using wildcards. The extension supports standard glob syntax: + +### Basic wildcards + +| Pattern | Matches | Example | +| ------- | --------------------------------------------------------- | ------------------------------------------------------------------ | +| `*` | Any sequence of characters within a single path component | `envs/*` matches `envs/project1` but not `envs/nested/project2` | +| `**` | Any sequence of path components (recursive) | `projects/**/.venv` matches `.venv` at any depth under `projects/` | +| `?` | Any single character | `project?` matches `project1`, `projectA` | +| `[...]` | Any character inside the brackets | `project[0-9]` matches `project0` through `project9` | + +### Pattern examples + +```json +{ + "python-envs.globalSearchPaths": [ + // Specific directory (no wildcard needed) + "/Users/username/main-env", + + // All direct subdirectories of envs/ + "/Users/username/envs/*", + + // All .venv directories at any depth + "/Users/username/projects/**/.venv", + + // All venv directories at any depth + "/Users/username/projects/**/venv", + + // Numbered project directories + "/Users/username/project[0-9]", + + // Multiple levels with wildcards + "/Users/username/clients/*/projects/*/env" + ] +} +``` + +## How Glob Expansion Works + +When you specify a glob pattern, the extension: + +1. **Expands the pattern** to find all matching directories +2. **Filters to directories only** (files are ignored unless they're Python executables) +3. **Searches each directory** recursively for Python environments + +### Example expansion + +Given the pattern `/Users/username/projects/**/.venv`: + +``` +projects/ +β”œβ”€β”€ backend/ +β”‚ └── .venv/ ← Matches +β”œβ”€β”€ frontend/ +β”‚ └── scripts/ +β”‚ └── .venv/ ← Matches +└── ml-pipeline/ + β”œβ”€β”€ training/ + β”‚ └── .venv/ ← Matches + └── inference/ + └── .venv/ ← Matches +``` + +All four `.venv` directories are added to the search paths. + +## Performance Considerations + +⚠️ **Important**: Glob patterns can significantly impact discovery performance if used incorrectly. + +### What to avoid + +| Pattern | Problem | Impact | +| -------------------- | ------------------------------ | -------------------------- | +| `/**` | Searches the entire filesystem | Very slow, may time out | +| `/Users/username/**` | Searches all user files | Extremely slow | +| `path/to/project/**` | Lists every subdirectory | Redundant, slows discovery | + +### Best practices + +βœ… **DO**: Use specific patterns + +```json +{ + "python-envs.workspaceSearchPaths": [ + ".venv", // Root-level .venv + "tests/**/.venv", // .venv directories under tests/ + "services/*/env" // env directories one level under services/ + ] +} +``` + +❌ **DON'T**: Use overly broad patterns + +```json +{ + "python-envs.workspaceSearchPaths": [ + "**", // Every directory! Very slow + "/Users/username/**" // Entire home directory! Extremely slow + ] +} +``` + +### Understanding `**` vs. no pattern + +| Configuration | Behavior | +| ----------------------- | ------------------------------------------------------------------------------ | +| `"/path/to/project"` | βœ… Extension searches this directory recursively for environments | +| `"/path/to/project/**"` | ⚠️ Extension treats EVERY subdirectory as a separate search path (inefficient) | + +> **Tip**: In most cases, you don't need `**` alone. Just specify the root directory and let the extension search recursively. + +## Common Use Cases + +### Find all .venv directories in a mono-repo + +```json +{ + "python-envs.workspaceSearchPaths": ["**/.venv"] +} +``` + +This finds `.venv` directories at any depth without treating every subdirectory as a search path. + +### Centralized environment storage + +```json +{ + "python-envs.globalSearchPaths": ["/Users/username/python-environments/*"] +} +``` + +This searches all direct subdirectories of your centralized environment folder. + +### Team convention: environments named "env" or "venv" + +```json +{ + "python-envs.workspaceSearchPaths": ["**/env", "**/venv"] +} +``` + +### Multiple project structures + +```json +{ + "python-envs.workspaceSearchPaths": [ + ".venv", // Root workspace environment + "backend/.venv", // Backend service environment + "services/*/venv", // Service-specific environments + "tests/**/test-env" // Test environments at any depth + ] +} +``` + +### Development and testing environments + +```json +{ + "python-envs.globalSearchPaths": ["/opt/python/dev/*", "/opt/python/test/*", "/Users/username/temp/envs/*"] +} +``` + +## Integration with Legacy Settings + +The extension merges custom search paths with legacy Python extension settings for backward compatibility. + +### Settings that are merged + +| Legacy Setting | Equivalent Modern Setting | +| -------------------- | ------------------------------------------- | +| `python.venvPath` | Merged into `python-envs.globalSearchPaths` | +| `python.venvFolders` | Merged into `python-envs.globalSearchPaths` | + +If you have both configured, the extension combines all paths into one search list. + +### Migration example + +**Before** (legacy Python extension): + +```json +{ + "python.venvPath": "/Users/username/envs", + "python.venvFolders": ["venv", ".venv"] +} +``` + +**After** (modern Python Environments): + +```json +{ + "python-envs.globalSearchPaths": ["/Users/username/envs/*", "**/venv", "**/.venv"] +} +``` + +> **Note**: You can continue using legacy settings, but migrating to `python-envs.globalSearchPaths` provides more flexibility with glob patterns. + +## Troubleshooting + +### Environments not appearing + +If your environments aren't discovered: + +1. **Verify paths are absolute** (for global search paths) or relative to workspace root (for workspace search paths) +2. **Check path separators**: Use `/` even on Windows +3. **Test without glob patterns first**: Start with a simple directory path, then add patterns +4. **Check extension logs**: Open **Output** panel and select **Python Environments** to see discovery logs +5. **Verify directory exists**: Glob patterns that match nothing are silently ignored + +### Slow environment discovery + +If discovery is taking too long: + +1. **Review glob patterns**: Look for overly broad patterns like `**` or `/Users/**` +2. **Be more specific**: Replace `projects/**` with `projects/**/.venv` to target specific directories +3. **Reduce search paths**: Remove paths that don't contain environments +4. **Use root directories**: Instead of `path/**`, use `path` and let the extension search recursively + +### Duplicate environments + +If environments appear multiple times: + +1. **Check for overlapping paths**: Ensure patterns don't match the same directories +2. **Remove redundant patterns**: If you specify both `projects/` and `projects/**/.venv`, the latter is sufficient +3. **Review workspace vs. global settings**: Ensure you're not duplicating paths across scopes + +## Quick Reference: Settings + +| Setting | Scope | Description | +| ---------------------------------- | ----------------- | -------------------------------------------------------------------------- | +| `python-envs.globalSearchPaths` | User or Workspace | Array of absolute paths or glob patterns searched across all workspaces | +| `python-envs.workspaceSearchPaths` | Workspace | Array of relative or absolute paths searched in the current workspace only | +| `python.venvPath` | User or Workspace | Legacy setting merged into global search paths | +| `python.venvFolders` | User or Workspace | Legacy setting merged into global search paths | + +## Pattern Reference + +### Quick pattern guide + +```json +{ + "python-envs.globalSearchPaths": [ + "/absolute/path", // Specific directory + "/parent/*", // Direct children only + "/parent/**/target", // Target directories at any depth + "/parent/child[0-9]", // Numbered children + "/parent/child?", // Single character wildcard + "/parent/{option1,option2}/env" // Alternative branches (if supported) + ] +} +``` + +### Platform-specific examples + +**macOS/Linux**: + +```json +{ + "python-envs.globalSearchPaths": [ + "/opt/python-envs/*", + "~/.local/share/virtualenvs/*", + "/usr/local/python-environments/*" + ] +} +``` + +**Windows**: + +```json +{ + "python-envs.globalSearchPaths": [ + "C:/Python/Environments/*", + "C:/Users/username/python-envs/*", + "D:/Development/*/venv" + ] +} +``` + +> **Note**: Use forward slashes `/` even on Windows. + +## Related Resources + +- [Managing Python Projects](managing-python-projects.md): Learn how to organize projects with their own environments +- [Environment Management](../README.md#environment-management): Learn about creating and managing Python environments +- [Settings Reference](../README.md#settings-reference): Complete list of extension settings diff --git a/package.json b/package.json index 9d14c779..a6cafa39 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "type": "string", "description": "%python-envs.defaultEnvManager.description%", "default": "ms-python.python:venv", - "scope": "window" + "scope": "application" }, "python-envs.defaultPackageManager": { "type": "string", @@ -213,6 +213,12 @@ "category": "Python", "icon": "$(refresh)" }, + { + "command": "python-envs.managerSearch", + "title": "%python-envs.managerSearch.title%", + "category": "Python", + "icon": "$(search)" + }, { "command": "python-envs.refreshPackages", "title": "%python-envs.refreshPackages.title%", @@ -545,6 +551,11 @@ "group": "navigation", "when": "view == env-managers" }, + { + "command": "python-envs.managerSearch", + "group": "navigation", + "when": "view == env-managers" + }, { "command": "python-envs.refreshAllManagers", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index 876385bf..b22fd3da 100644 --- a/package.nls.json +++ b/package.nls.json @@ -31,6 +31,7 @@ "python-envs.setEnvSelected.title": "Set!", "python-envs.remove.title": "Delete Environment", "python-envs.refreshAllManagers.title": "Refresh All Environment Managers", + "python-envs.managerSearch.title": "Manage Environment Search", "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", diff --git a/src/common/localize.ts b/src/common/localize.ts index 7191a184..699e2d5c 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -198,6 +198,16 @@ export namespace EnvViewStrings { export const selectedWorkspaceTooltip = l10n.t('This environment is selected for project files'); } +export namespace EnvManagerSearchStrings { + export const selectAction = l10n.t('Select an action'); + export const adjustSearchPaths = l10n.t('Adjust search path settings'); + export const adjustSearchPathsDescription = l10n.t('Open settings for environment search paths'); + export const fullWorkspaceSearch = l10n.t('Do full workspace search'); + export const fullWorkspaceSearchDescription = l10n.t('Search the entire workspace for environments'); + export const saveSearchPrompt = l10n.t('Save this search setting for future discovery?'); + export const dontShowAgain = l10n.t("Don't show again"); +} + export namespace ActivationStrings { export const envCollectionDescription = l10n.t('Environment variables for shell activation'); export const revertedShellStartupScripts = l10n.t( diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index d398828a..948601c0 100644 --- a/src/common/utils/pathUtils.ts +++ b/src/common/utils/pathUtils.ts @@ -65,6 +65,24 @@ export function normalizePath(fsPath: string): string { return path1; } +/** + * Normalizes a search path for comparison while preserving relative and glob strings. + * Absolute paths are resolved; relative/glob paths are trimmed and left intact. + */ +export function normalizePathKeepGlobs(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + if (path.isAbsolute(trimmed)) { + const resolved = path.resolve(trimmed); + return isWindows() ? resolved.toLowerCase() : resolved; + } + + return isWindows() ? trimmed.toLowerCase() : trimmed; +} + export function getResourceUri(resourcePath: string, root?: string): Uri | undefined { try { if (!resourcePath) { diff --git a/src/extension.ts b/src/extension.ts index 09138489..168d96a7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -66,6 +66,7 @@ import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInject import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; +import { handleEnvManagerSearchAction } from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; @@ -147,6 +148,7 @@ export async function activate(context: ExtensionContext): Promise(); + const nativeFinderDeferred = createDeferred(); const temporaryStateManager = new TemporaryStateManager(); context.subscriptions.push(temporaryStateManager); @@ -179,6 +181,10 @@ export async function activate(context: ExtensionContext): Promise { await Promise.all(envManagers.managers.map((m) => m.refresh(undefined))); }), + commands.registerCommand('python-envs.managerSearch', async () => { + const nativeFinder = await nativeFinderDeferred.promise; + await handleEnvManagerSearchAction(envManagers, nativeFinder); + }), commands.registerCommand('python-envs.refreshPackages', async (item) => { await refreshPackagesCommand(item, envManagers); }), @@ -448,6 +454,7 @@ export async function activate(context: ExtensionContext): Promise { // This is the finder that is used by all the built in environment managers const nativeFinder: NativePythonFinder = await createNativePythonFinder(outputChannel, api, context); + nativeFinderDeferred.resolve(nativeFinder); context.subscriptions.push(nativeFinder); const sysMgr = new SysPythonManager(nativeFinder, api, outputChannel); sysPythonManager.resolve(sysMgr); diff --git a/src/features/views/envManagerSearch.ts b/src/features/views/envManagerSearch.ts new file mode 100644 index 00000000..a3a5c249 --- /dev/null +++ b/src/features/views/envManagerSearch.ts @@ -0,0 +1,138 @@ +import * as path from 'path'; +import { commands, ConfigurationTarget, QuickPickItem, window } from 'vscode'; +import { Common, EnvManagerSearchStrings } from '../../common/localize'; +import { traceLog } from '../../common/logging'; +import { getWorkspacePersistentState } from '../../common/persistentState'; +import { normalizePathKeepGlobs } from '../../common/utils/pathUtils'; +import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; +import { EnvironmentManagers } from '../../internal.api'; +import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; + +type SearchAction = 'settings' | 'fullSearch'; + +interface SearchActionItem extends QuickPickItem { + action: SearchAction; +} + +const SUPPRESS_SAVE_PROMPT_KEY = 'python-envs.search.fullWorkspace.suppressSavePrompt'; + +/** + * Handles the "Manage Environment Search" action from the Environment Managers view. + * Presents a quick pick menu allowing users to either adjust search path settings + * or perform a full workspace search for Python environments. + */ +export async function handleEnvManagerSearchAction( + envManagers: EnvironmentManagers, + nativeFinder: NativePythonFinder, +): Promise { + const items: SearchActionItem[] = [ + { + label: EnvManagerSearchStrings.adjustSearchPaths, + description: EnvManagerSearchStrings.adjustSearchPathsDescription, + action: 'settings', + }, + { + label: EnvManagerSearchStrings.fullWorkspaceSearch, + description: EnvManagerSearchStrings.fullWorkspaceSearchDescription, + action: 'fullSearch', + }, + ]; + + const selection = await window.showQuickPick(items, { + placeHolder: EnvManagerSearchStrings.selectAction, + matchOnDescription: true, + }); + + if (!selection) { + return; + } + + if (selection.action === 'settings') { + await openSearchSettings(); + return; + } + + await runFullWorkspaceSearch(envManagers, nativeFinder); +} + +async function openSearchSettings(): Promise { + await commands.executeCommand('workbench.action.openSettings', '@ext:ms-python.vscode-python-envs "search path"'); +} + +/** + * Performs a recursive search for Python environments across all workspace folders. + * Uses the `./**` glob pattern to search the entire workspace tree. + * After the search completes, prompts the user to save the search pattern to settings. + */ +async function runFullWorkspaceSearch( + envManagers: EnvironmentManagers, + nativeFinder: NativePythonFinder, +): Promise { + const workspaceFolders = getWorkspaceFolders(); + if (!workspaceFolders || workspaceFolders.length === 0) { + return; + } + + // Construct search paths for all workspace folders + const searchPaths = workspaceFolders.map((folder) => path.join(folder.uri.fsPath, '**')); + traceLog('Full workspace search:', searchPaths); + + nativeFinder.setTemporarySearchPaths(searchPaths); + try { + await Promise.all(envManagers.managers.map((manager) => manager.refresh(undefined))); + } finally { + nativeFinder.setTemporarySearchPaths(undefined); + } + + await promptToSaveSearchPaths(['./**']); +} + +/** + * Prompts the user to save the search paths to workspace settings. + * Respects the user's "Don't show again" preference stored in persistent state. + */ +async function promptToSaveSearchPaths(searchPaths: string[]): Promise { + const state = await getWorkspacePersistentState(); + const suppressPrompt = await state.get(SUPPRESS_SAVE_PROMPT_KEY, false); + if (suppressPrompt) { + return; + } + + const response = await window.showInformationMessage( + EnvManagerSearchStrings.saveSearchPrompt, + Common.yes, + Common.no, + EnvManagerSearchStrings.dontShowAgain, + ); + + if (response === EnvManagerSearchStrings.dontShowAgain) { + await state.set(SUPPRESS_SAVE_PROMPT_KEY, true); + return; + } + + if (response === Common.yes) { + await appendWorkspaceSearchPaths(searchPaths); + } +} + +/** + * Appends new search paths to the workspace-level `workspaceSearchPaths` setting. + * Deduplicates paths using case-insensitive comparison on Windows. + */ +export async function appendWorkspaceSearchPaths(searchPaths: string[]): Promise { + const config = getConfiguration('python-envs'); + const inspection = config.inspect('workspaceSearchPaths'); + const currentPaths = inspection?.workspaceValue ?? []; + const normalizedCurrent = new Set(currentPaths.map((value) => normalizePathKeepGlobs(value))); + const filteredSearchPaths = searchPaths.filter((value) => { + const normalized = normalizePathKeepGlobs(value); + return normalized && !normalizedCurrent.has(normalized); + }); + + if (filteredSearchPaths.length === 0) { + return; + } + + const nextPaths = [...currentPaths, ...filteredSearchPaths]; + await config.update('workspaceSearchPaths', nextPaths, ConfigurationTarget.Workspace); +} diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index b0534079..05f81e7e 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -8,11 +8,11 @@ import { PythonProjectApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants'; import { getExtension } from '../../common/extension.apis'; -import { traceError, traceLog, traceWarn } from '../../common/logging'; +import { traceError, traceLog } from '../../common/logging'; import { untildify, untildifyArray } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { createRunningWorkerPool, WorkerPool } from '../../common/utils/workerPool'; -import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; +import { getConfiguration } from '../../common/workspace.apis'; import { noop } from './utils'; // Timeout constants for JSON-RPC requests (in milliseconds) @@ -103,6 +103,11 @@ export interface NativePythonFinder extends Disposable { * @param executable */ resolve(executable: string): Promise; + /** + * Sets temporary search paths used for the next discovery refresh. + * These paths are not persisted to user or workspace settings. + */ + setTemporarySearchPaths(searchPaths?: string[]): void; } interface NativeLog { level: string; @@ -156,6 +161,7 @@ class NativePythonFinderImpl implements NativePythonFinder { private startFailed: boolean = false; private restartAttempts: number = 0; private isRestarting: boolean = false; + private temporarySearchPaths: string[] | undefined; constructor( private readonly outputChannel: LogOutputChannel, @@ -197,6 +203,10 @@ class NativePythonFinderImpl implements NativePythonFinder { } } + public setTemporarySearchPaths(searchPaths?: string[]): void { + this.temporarySearchPaths = searchPaths?.filter((value) => value && value.trim() !== ''); + } + /** * Ensures the PET process is running. If it has exited or failed, attempts to restart * with exponential backoff up to MAX_RESTART_ATTEMPTS times. @@ -563,10 +573,12 @@ class NativePythonFinderImpl implements NativePythonFinder { private async configure() { // Get all extra search paths including legacy settings and new searchPaths const extraSearchPaths = await getAllExtraSearchPaths(); + const temporarySearchPaths = this.temporarySearchPaths ?? []; + const environmentDirectories = Array.from(new Set([...extraSearchPaths, ...temporarySearchPaths])); const options: ConfigurationOptions = { workspaceDirectories: this.api.getPythonProjects().map((item) => item.uri.fsPath), - environmentDirectories: extraSearchPaths, + environmentDirectories, condaExecutable: getPythonSettingAndUntildify('condaPath'), pipenvExecutable: getPythonSettingAndUntildify('pipenvPath'), poetryExecutable: getPythonSettingAndUntildify('poetryPath'), @@ -685,29 +697,13 @@ export async function getAllExtraSearchPaths(): Promise { // Get workspaceSearchPaths const workspaceSearchPaths = getWorkspaceSearchPaths(); - // Resolve relative paths against workspace folders + // Keep workspaceSearchPaths entries as provided (no workspace prefixing). for (const searchPath of workspaceSearchPaths) { if (!searchPath || searchPath.trim() === '') { continue; } - const trimmedPath = searchPath.trim(); - - if (path.isAbsolute(trimmedPath)) { - // Absolute path - use as is - searchDirectories.push(trimmedPath); - } else { - // Relative path - resolve against all workspace folders - const workspaceFolders = getWorkspaceFolders(); - if (workspaceFolders) { - for (const workspaceFolder of workspaceFolders) { - const resolvedPath = path.resolve(workspaceFolder.uri.fsPath, trimmedPath); - searchDirectories.push(resolvedPath); - } - } else { - traceWarn('Warning: No workspace folders found for relative path:', trimmedPath); - } - } + searchDirectories.push(searchPath.trim()); } // Remove duplicates and return @@ -748,7 +744,7 @@ function getWorkspaceSearchPaths(): string[] { if (inspection?.globalValue) { traceError( - 'Error: python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.', + 'python-envs.workspaceSearchPaths is set at the user/global level, but this setting can only be set at the workspace or workspace folder level.', ); } diff --git a/src/test/common/pathUtils.unit.test.ts b/src/test/common/pathUtils.unit.test.ts index 1733e789..94a79372 100644 --- a/src/test/common/pathUtils.unit.test.ts +++ b/src/test/common/pathUtils.unit.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; -import { getResourceUri, normalizePath } from '../../common/utils/pathUtils'; +import { getResourceUri, normalizePath, normalizePathKeepGlobs } from '../../common/utils/pathUtils'; import * as utils from '../../common/utils/platformUtils'; suite('Path Utilities', () => { @@ -128,4 +128,88 @@ suite('Path Utilities', () => { assert.strictEqual(result, 'C:/Path/To/File.txt'); }); }); + + suite('normalizePathKeepGlobs', () => { + teardown(() => { + sinon.restore(); + }); + + suite('on POSIX systems', () => { + setup(() => { + sinon.stub(utils, 'isWindows').returns(false); + }); + + test('returns empty string for empty input', () => { + assert.strictEqual(normalizePathKeepGlobs(''), ''); + }); + + test('returns empty string for whitespace-only input', () => { + assert.strictEqual(normalizePathKeepGlobs(' '), ''); + }); + + test('trims whitespace from relative paths', () => { + assert.strictEqual(normalizePathKeepGlobs(' .venv '), '.venv'); + }); + + test('preserves relative paths as-is', () => { + assert.strictEqual(normalizePathKeepGlobs('./**'), './**'); + assert.strictEqual(normalizePathKeepGlobs('envs/test'), 'envs/test'); + }); + + test('resolves absolute paths', () => { + const result = normalizePathKeepGlobs('/home/user/envs'); + assert.strictEqual(result, '/home/user/envs'); + }); + + test('preserves case for relative paths', () => { + assert.strictEqual(normalizePathKeepGlobs('MyEnv'), 'MyEnv'); + }); + + test('preserves case for absolute paths', () => { + const result = normalizePathKeepGlobs('/home/User/Envs'); + assert.ok(result.includes('User') || result.includes('user')); + }); + }); + + suite('on Windows systems', () => { + setup(() => { + sinon.stub(utils, 'isWindows').returns(true); + }); + + test('lowercases relative paths', () => { + assert.strictEqual(normalizePathKeepGlobs('.VENV'), '.venv'); + assert.strictEqual(normalizePathKeepGlobs('MyEnv'), 'myenv'); + }); + + test('lowercases absolute paths', () => { + // On Windows, path.resolve would handle the path + // The important part is that the result is lowercased + const result = normalizePathKeepGlobs('C:\\Users\\Test'); + assert.strictEqual(result, result.toLowerCase()); + }); + + test('handles glob patterns', () => { + assert.strictEqual(normalizePathKeepGlobs('./**'), './**'); + assert.strictEqual(normalizePathKeepGlobs('**/.venv'), '**/.venv'); + }); + }); + + suite('path normalization consistency', () => { + setup(() => { + sinon.stub(utils, 'isWindows').returns(false); + }); + + test('same paths normalize to same value', () => { + const path1 = normalizePathKeepGlobs('.venv'); + const path2 = normalizePathKeepGlobs(' .venv '); + assert.strictEqual(path1, path2); + }); + + test('different paths normalize differently', () => { + const path1 = normalizePathKeepGlobs('.venv'); + const path2 = normalizePathKeepGlobs('venv'); + assert.notStrictEqual(path1, path2); + }); + }); + }); }); diff --git a/src/test/features/views/envManagerSearch.unit.test.ts b/src/test/features/views/envManagerSearch.unit.test.ts new file mode 100644 index 00000000..ff062389 --- /dev/null +++ b/src/test/features/views/envManagerSearch.unit.test.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert'; +import * as sinon from 'sinon'; +import { ConfigurationTarget, WorkspaceConfiguration } from 'vscode'; +import * as platformUtils from '../../../common/utils/platformUtils'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { appendWorkspaceSearchPaths } from '../../../features/views/envManagerSearch'; + +type UpdateCall = { key: string; value: unknown; target?: ConfigurationTarget | boolean }; + +suite('Environment Manager Search', () => { + suite('appendWorkspaceSearchPaths', () => { + let updateCalls: UpdateCall[]; + + function createMockConfig(workspaceValue: string[]) { + updateCalls = []; + return { + inspect: sinon.stub().returns({ workspaceValue }), + update: sinon + .stub() + .callsFake((section: string, value: unknown, target?: ConfigurationTarget | boolean) => { + updateCalls.push({ key: section, value, target }); + return Promise.resolve(); + }), + } as unknown as WorkspaceConfiguration; + } + + teardown(() => { + sinon.restore(); + }); + + test('does not update when all paths are duplicates or empty', async () => { + sinon.stub(platformUtils, 'isWindows').returns(false); + const mockConfig = createMockConfig(['.venv', 'envs/existing']); + const getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + await appendWorkspaceSearchPaths([' .venv ', ' ', 'envs/existing']); + + assert.strictEqual(getConfigurationStub.calledOnce, true); + assert.strictEqual(updateCalls.length, 0); + }); + + test('appends new paths to workspace search paths', async () => { + sinon.stub(platformUtils, 'isWindows').returns(false); + const mockConfig = createMockConfig(['.venv']); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + await appendWorkspaceSearchPaths(['envs/new', ' .venv ']); + + assert.strictEqual(updateCalls.length, 1); + assert.strictEqual(updateCalls[0].key, 'workspaceSearchPaths'); + assert.deepStrictEqual(updateCalls[0].value, ['.venv', 'envs/new']); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + }); + + test('dedupes paths case-insensitively on Windows', async () => { + sinon.stub(platformUtils, 'isWindows').returns(true); + const mockConfig = createMockConfig(['ENV']); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + await appendWorkspaceSearchPaths(['env']); + + assert.strictEqual(updateCalls.length, 0); + }); + }); +}); diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 19de0841..d5db6c9e 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import path from 'node:path'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import * as logging from '../../../common/logging'; @@ -224,11 +223,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Use dynamic path construction based on actual workspace URIs - const expected = new Set([ - path.resolve(workspace1.fsPath, 'folder-level-path'), - path.resolve(workspace2.fsPath, 'folder-level-path'), - ]); + // Assert - Relative entries are kept as provided + const expected = new Set(['folder-level-path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -304,7 +300,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Relative paths resolved against workspace folders', async () => { + test('Relative paths are kept as provided', async () => { // Mock β†’ Relative workspace paths with multiple workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); @@ -320,19 +316,14 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - path.resolve() correctly resolves relative paths (order doesn't matter) - const expected = new Set([ - path.resolve(workspace1.fsPath, 'venvs'), - path.resolve(workspace2.fsPath, 'venvs'), - path.resolve(workspace1.fsPath, '../shared-envs'), // Resolves against workspace1 - path.resolve(workspace2.fsPath, '../shared-envs'), // Resolves against workspace2 - ]); + // Assert - Relative paths are not resolved against workspace folders (order doesn't matter) + const expected = new Set(['venvs', '../shared-envs']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Relative paths without workspace folders logs warning', async () => { + test('Relative paths without workspace folders are kept', async () => { // Mock β†’ Relative paths but no workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); @@ -347,12 +338,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { const result = await getAllExtraSearchPaths(); // Assert - assert.deepStrictEqual(result, []); - // Check that warning was logged with key terms - don't be brittle about exact wording - assert( - mockTraceWarn.calledWith(sinon.match(/workspace.*folder.*relative.*path/i), 'relative-path'), - 'Should log warning about missing workspace folders', - ); + assert.deepStrictEqual(result, ['relative-path']); + assert.strictEqual(mockTraceWarn.called, false, 'Should not warn when keeping relative paths'); }); test('Empty and whitespace paths are skipped', async () => { @@ -376,8 +363,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { const expected = new Set([ '/valid/path', '/another/valid/path', - path.resolve(workspace.fsPath, 'valid-relative'), - path.resolve(workspace.fsPath, 'another-valid'), + 'valid-relative', + 'another-valid', ]); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); @@ -426,10 +413,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { '/legacy/venvs', '/global/conda', '/home/user/personal/envs', - path.resolve(workspace1.fsPath, '.venv'), - path.resolve(workspace2.fsPath, '.venv'), - path.resolve(workspace1.fsPath, 'project-envs'), - path.resolve(workspace2.fsPath, 'project-envs'), + '.venv', + 'project-envs', '/shared/team/envs', ]); const actual = new Set(result); @@ -460,7 +445,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { const expected = new Set([ '/shared/path', '/global/unique', - path.resolve(workspace.fsPath, 'workspace-unique'), + 'workspace-unique', ]); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); @@ -487,7 +472,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { '/legacy/path', '/legacy/folder', '/global/path', - path.resolve(workspace.fsPath, 'workspace-relative'), + 'workspace-relative', ]); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); From 74ec72f51feb0531e0e959489f42415933a2a544 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:38:06 -0800 Subject: [PATCH 02/10] ish --- package.json | 11 ++++++ package.nls.json | 3 +- src/extension.ts | 9 ++--- src/features/views/envManagerSearch.ts | 47 +++++--------------------- 4 files changed, 26 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index a6cafa39..80020e1d 100644 --- a/package.json +++ b/package.json @@ -219,6 +219,12 @@ "category": "Python", "icon": "$(search)" }, + { + "command": "python-envs.searchSettings", + "title": "%python-envs.searchSettings.title%", + "category": "Python", + "icon": "$(gear)" + }, { "command": "python-envs.refreshPackages", "title": "%python-envs.refreshPackages.title%", @@ -556,6 +562,11 @@ "group": "navigation", "when": "view == env-managers" }, + { + "command": "python-envs.searchSettings", + "group": "navigation", + "when": "view == env-managers" + }, { "command": "python-envs.refreshAllManagers", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index b22fd3da..32611aab 100644 --- a/package.nls.json +++ b/package.nls.json @@ -31,7 +31,8 @@ "python-envs.setEnvSelected.title": "Set!", "python-envs.remove.title": "Delete Environment", "python-envs.refreshAllManagers.title": "Refresh All Environment Managers", - "python-envs.managerSearch.title": "Manage Environment Search", + "python-envs.managerSearch.title": "Search Workspace for Environments", + "python-envs.searchSettings.title": "Configure Search Settings", "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", diff --git a/src/extension.ts b/src/extension.ts index 168d96a7..36ed5996 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,8 +1,7 @@ import { commands, ExtensionContext, LogOutputChannel, Terminal, Uri, window } from 'vscode'; -import { version as extensionVersion } from '../package.json'; import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api'; import { ensureCorrectVersion } from './common/extVersion'; -import { registerLogger, traceError, traceInfo, traceVerbose, traceWarn } from './common/logging'; +import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; import { clearPersistentState, setPersistentState } from './common/persistentState'; import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; @@ -66,7 +65,7 @@ import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInject import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; -import { handleEnvManagerSearchAction } from './features/views/envManagerSearch'; +import { handleEnvManagerSearchAction, openSearchSettings } from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; @@ -103,7 +102,6 @@ export async function activate(context: ExtensionContext): Promise { + await openSearchSettings(); + }), commands.registerCommand('python-envs.refreshPackages', async (item) => { await refreshPackagesCommand(item, envManagers); }), diff --git a/src/features/views/envManagerSearch.ts b/src/features/views/envManagerSearch.ts index a3a5c249..4fa0c089 100644 --- a/src/features/views/envManagerSearch.ts +++ b/src/features/views/envManagerSearch.ts @@ -1,5 +1,5 @@ import * as path from 'path'; -import { commands, ConfigurationTarget, QuickPickItem, window } from 'vscode'; +import { commands, ConfigurationTarget, window } from 'vscode'; import { Common, EnvManagerSearchStrings } from '../../common/localize'; import { traceLog } from '../../common/logging'; import { getWorkspacePersistentState } from '../../common/persistentState'; @@ -8,54 +8,23 @@ import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.ap import { EnvironmentManagers } from '../../internal.api'; import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; -type SearchAction = 'settings' | 'fullSearch'; - -interface SearchActionItem extends QuickPickItem { - action: SearchAction; -} - const SUPPRESS_SAVE_PROMPT_KEY = 'python-envs.search.fullWorkspace.suppressSavePrompt'; /** - * Handles the "Manage Environment Search" action from the Environment Managers view. - * Presents a quick pick menu allowing users to either adjust search path settings - * or perform a full workspace search for Python environments. + * Handles the Environment Managers view search action. + * Performs a full workspace search for Python environments. */ export async function handleEnvManagerSearchAction( envManagers: EnvironmentManagers, nativeFinder: NativePythonFinder, ): Promise { - const items: SearchActionItem[] = [ - { - label: EnvManagerSearchStrings.adjustSearchPaths, - description: EnvManagerSearchStrings.adjustSearchPathsDescription, - action: 'settings', - }, - { - label: EnvManagerSearchStrings.fullWorkspaceSearch, - description: EnvManagerSearchStrings.fullWorkspaceSearchDescription, - action: 'fullSearch', - }, - ]; - - const selection = await window.showQuickPick(items, { - placeHolder: EnvManagerSearchStrings.selectAction, - matchOnDescription: true, - }); - - if (!selection) { - return; - } - - if (selection.action === 'settings') { - await openSearchSettings(); - return; - } - await runFullWorkspaceSearch(envManagers, nativeFinder); } -async function openSearchSettings(): Promise { +/** + * Opens environment search settings. + */ +export async function openSearchSettings(): Promise { await commands.executeCommand('workbench.action.openSettings', '@ext:ms-python.vscode-python-envs "search path"'); } @@ -64,7 +33,7 @@ async function openSearchSettings(): Promise { * Uses the `./**` glob pattern to search the entire workspace tree. * After the search completes, prompts the user to save the search pattern to settings. */ -async function runFullWorkspaceSearch( +export async function runFullWorkspaceSearch( envManagers: EnvironmentManagers, nativeFinder: NativePythonFinder, ): Promise { From 5aed3870792a2d4896fc4cc8d9f901b3718e0728 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:09:54 -0800 Subject: [PATCH 03/10] simplified solution on gear --- package.json | 7 +- package.nls.json | 4 +- src/common/localize.ts | 6 ++ src/extension.ts | 35 ++++--- src/features/views/envManagerSearch.ts | 62 +++++++++++- src/managers/common/nativePythonFinder.ts | 37 ++++--- ...Finder.getAllExtraSearchPaths.unit.test.ts | 99 ++++++++----------- 7 files changed, 158 insertions(+), 92 deletions(-) diff --git a/package.json b/package.json index 80020e1d..7ea7f1d7 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [], + "default": ["./**/.venv"], "scope": "resource", "items": { "type": "string" @@ -557,11 +557,6 @@ "group": "navigation", "when": "view == env-managers" }, - { - "command": "python-envs.managerSearch", - "group": "navigation", - "when": "view == env-managers" - }, { "command": "python-envs.searchSettings", "group": "navigation", diff --git a/package.nls.json b/package.nls.json index 32611aab..26b158ba 100644 --- a/package.nls.json +++ b/package.nls.json @@ -11,8 +11,8 @@ "python-envs.terminal.autoActivationType.shellStartup": "Activation using [shell integration](https://code.visualstudio.com/docs/terminal/shell-integration) or by modifying the terminal shell startup script. Enable `terminal.integrated.shellIntegration.enabled` or we may need to modify your shell startup scripts for the ideal experience.", "python-envs.terminal.autoActivationType.off": "No automatic activation of environments.", "python-envs.terminal.useEnvFile.description": "Controls whether environment variables from .env files and python.envFile setting are injected into terminals.", - "python-envs.globalSearchPaths.description": "Global search paths for Python environments. Absolute directory paths that are searched at the user level.\n\n**Legacy Setting Support:** This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. All paths from these three settings are combined into a single list of search paths. The legacy settings `python.venvPath` and `python.venvFolders` will be deprecated in the future, after which this setting will fully replace them. Please consider migrating your paths to this setting.", - "python-envs.workspaceSearchPaths.description": "Workspace search paths for Python environments. Can be absolute paths or relative directory paths searched within the workspace.", + "python-envs.globalSearchPaths.description": "Absolute paths to search for Python environments across all workspaces. Use for shared environment folders like `~/envs`.", + "python-envs.workspaceSearchPaths.description": "Paths to search for environments in this workspace. Defaults to `./**/.venv` to find all `.venv` folders nested within your workspace.", "python-envs.terminal.revertStartupScriptChanges.title": "Revert Shell Startup Script Changes", "python-envs.reportIssue.title": "Report Issue", "python-envs.setEnvManager.title": "Set Environment Manager", diff --git a/src/common/localize.ts b/src/common/localize.ts index 699e2d5c..34bf5b5b 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -206,6 +206,12 @@ export namespace EnvManagerSearchStrings { export const fullWorkspaceSearchDescription = l10n.t('Search the entire workspace for environments'); export const saveSearchPrompt = l10n.t('Save this search setting for future discovery?'); export const dontShowAgain = l10n.t("Don't show again"); + export const slowLoadingMessage = l10n.t( + 'Environment discovery is taking longer than expected. This may be due to workspace search paths.', + ); + export const openSettings = l10n.t('Open Settings'); + export const removeWorkspaceSearch = l10n.t('Remove Workspace Search'); + export const dontShowForWorkspace = l10n.t("Don't Show for This Workspace"); } export namespace ActivationStrings { diff --git a/src/extension.ts b/src/extension.ts index 36ed5996..d3813baf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -65,7 +65,11 @@ import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInject import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; -import { handleEnvManagerSearchAction, openSearchSettings } from './features/views/envManagerSearch'; +import { + handleEnvManagerSearchAction, + openSearchSettings, + startSlowLoadingMonitor, +} from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; @@ -453,22 +457,31 @@ export async function activate(context: ExtensionContext): Promise { + // Start monitoring for slow loading - will show notification if discovery takes too long + const cancelSlowLoadingMonitor = startSlowLoadingMonitor(); + // This is the finder that is used by all the built in environment managers const nativeFinder: NativePythonFinder = await createNativePythonFinder(outputChannel, api, context); nativeFinderDeferred.resolve(nativeFinder); context.subscriptions.push(nativeFinder); const sysMgr = new SysPythonManager(nativeFinder, api, outputChannel); sysPythonManager.resolve(sysMgr); - await Promise.all([ - registerSystemPythonFeatures(nativeFinder, context.subscriptions, outputChannel, sysMgr), - registerCondaFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), - registerPyenvFeatures(nativeFinder, context.subscriptions, projectManager), - registerPipenvFeatures(nativeFinder, context.subscriptions, projectManager), - registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), - shellStartupVarsMgr.initialize(), - ]); - - await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api); + + try { + await Promise.all([ + registerSystemPythonFeatures(nativeFinder, context.subscriptions, outputChannel, sysMgr), + registerCondaFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), + registerPyenvFeatures(nativeFinder, context.subscriptions, projectManager), + registerPipenvFeatures(nativeFinder, context.subscriptions, projectManager), + registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), + shellStartupVarsMgr.initialize(), + ]); + + await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api); + } finally { + // Cancel the slow loading monitor once initialization completes (or fails) + cancelSlowLoadingMonitor(); + } // Register manager-agnostic terminal watcher for package-modifying commands registerTerminalPackageWatcher(api, terminalActivation, outputChannel, context.subscriptions); diff --git a/src/features/views/envManagerSearch.ts b/src/features/views/envManagerSearch.ts index 4fa0c089..75f94f1f 100644 --- a/src/features/views/envManagerSearch.ts +++ b/src/features/views/envManagerSearch.ts @@ -9,6 +9,8 @@ import { EnvironmentManagers } from '../../internal.api'; import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; const SUPPRESS_SAVE_PROMPT_KEY = 'python-envs.search.fullWorkspace.suppressSavePrompt'; +const SUPPRESS_SLOW_LOADING_KEY = 'python-envs.search.slowLoading.suppressPrompt'; +const SLOW_LOADING_THRESHOLD_MS = 10_000; // 10 seconds /** * Handles the Environment Managers view search action. @@ -22,10 +24,13 @@ export async function handleEnvManagerSearchAction( } /** - * Opens environment search settings. + * Opens environment search settings at workspace level. */ export async function openSearchSettings(): Promise { - await commands.executeCommand('workbench.action.openSettings', '@ext:ms-python.vscode-python-envs "search path"'); + await commands.executeCommand( + 'workbench.action.openWorkspaceSettings', + '@ext:ms-python.vscode-python-envs "search path"', + ); } /** @@ -105,3 +110,56 @@ export async function appendWorkspaceSearchPaths(searchPaths: string[]): Promise const nextPaths = [...currentPaths, ...filteredSearchPaths]; await config.update('workspaceSearchPaths', nextPaths, ConfigurationTarget.Workspace); } + +/** + * Clears the workspace-level `workspaceSearchPaths` setting. + */ +export async function clearWorkspaceSearchPaths(): Promise { + const config = getConfiguration('python-envs'); + await config.update('workspaceSearchPaths', [], ConfigurationTarget.Workspace); + traceLog('Cleared workspace search paths'); +} + +/** + * Monitors environment refresh and shows a notification if it takes too long. + * Returns a cleanup function to cancel the timeout if refresh completes early. + */ +export function startSlowLoadingMonitor(): () => void { + let timeoutId: NodeJS.Timeout | undefined; + let notificationShown = false; + + const showNotification = async (): Promise => { + const state = await getWorkspacePersistentState(); + const suppressNotification = await state.get(SUPPRESS_SLOW_LOADING_KEY, false); + if (suppressNotification || notificationShown) { + return; + } + notificationShown = true; + + const response = await window.showWarningMessage( + EnvManagerSearchStrings.slowLoadingMessage, + EnvManagerSearchStrings.openSettings, + EnvManagerSearchStrings.removeWorkspaceSearch, + EnvManagerSearchStrings.dontShowForWorkspace, + ); + + if (response === EnvManagerSearchStrings.openSettings) { + await openSearchSettings(); + } else if (response === EnvManagerSearchStrings.removeWorkspaceSearch) { + await clearWorkspaceSearchPaths(); + } else if (response === EnvManagerSearchStrings.dontShowForWorkspace) { + await state.set(SUPPRESS_SLOW_LOADING_KEY, true); + } + }; + + timeoutId = setTimeout(() => { + showNotification(); + }, SLOW_LOADING_THRESHOLD_MS); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; +} diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index 05f81e7e..c336c4a2 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -8,11 +8,11 @@ import { PythonProjectApi } from '../../api'; import { spawnProcess } from '../../common/childProcess.apis'; import { ENVS_EXTENSION_ID, PYTHON_EXTENSION_ID } from '../../common/constants'; import { getExtension } from '../../common/extension.apis'; -import { traceError, traceLog } from '../../common/logging'; +import { traceError, traceVerbose, traceWarn } from '../../common/logging'; import { untildify, untildifyArray } from '../../common/utils/pathUtils'; import { isWindows } from '../../common/utils/platformUtils'; import { createRunningWorkerPool, WorkerPool } from '../../common/utils/workerPool'; -import { getConfiguration } from '../../common/workspace.apis'; +import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; import { noop } from './utils'; // Timeout constants for JSON-RPC requests (in milliseconds) @@ -697,23 +697,34 @@ export async function getAllExtraSearchPaths(): Promise { // Get workspaceSearchPaths const workspaceSearchPaths = getWorkspaceSearchPaths(); - // Keep workspaceSearchPaths entries as provided (no workspace prefixing). + // Resolve relative paths against workspace folders for (const searchPath of workspaceSearchPaths) { if (!searchPath || searchPath.trim() === '') { continue; } - searchDirectories.push(searchPath.trim()); + const trimmedPath = searchPath.trim(); + + if (path.isAbsolute(trimmedPath)) { + // Absolute path - use as is + searchDirectories.push(trimmedPath); + } else { + // Relative path - resolve against all workspace folders + const workspaceFolders = getWorkspaceFolders(); + if (workspaceFolders) { + for (const workspaceFolder of workspaceFolders) { + const resolvedPath = path.resolve(workspaceFolder.uri.fsPath, trimmedPath); + searchDirectories.push(resolvedPath); + } + } else { + traceWarn('No workspace folders found for relative search path:', trimmedPath); + } + } } // Remove duplicates and return const uniquePaths = Array.from(new Set(searchDirectories)); - traceLog( - 'getAllExtraSearchPaths completed. Total unique search directories:', - uniquePaths.length, - 'Paths:', - uniquePaths, - ); + traceVerbose('Environment search directories:', uniquePaths.length, 'paths'); return uniquePaths; } @@ -748,7 +759,7 @@ function getWorkspaceSearchPaths(): string[] { ); } - // For workspace settings, prefer workspaceFolder > workspace + // For workspace settings, prefer workspaceFolder > workspace > default if (inspection?.workspaceFolderValue) { return inspection.workspaceFolderValue; } @@ -757,8 +768,8 @@ function getWorkspaceSearchPaths(): string[] { return inspection.workspaceValue; } - // Default empty array (don't use global value for workspace settings) - return []; + // Use the default value from package.json + return inspection?.defaultValue ?? []; } catch (error) { traceError('Error getting workspaceSearchPaths:', error); return []; diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index d5db6c9e..dbe2ac4f 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -212,8 +212,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceValue: ['workspace-level-path'], - workspaceFolderValue: ['folder-level-path'], + workspaceValue: ['/workspace-level-path'], + workspaceFolderValue: ['/folder-level-path'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -223,8 +223,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Relative entries are kept as provided - const expected = new Set(['folder-level-path']); + // Assert - workspaceFolderValue takes priority, absolute path is kept as-is + const expected = new Set(['/folder-level-path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -300,13 +300,13 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Relative paths are kept as provided', async () => { + test('Relative paths are resolved against workspace folders', async () => { // Mock β†’ Relative workspace paths with multiple workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['venvs', '../shared-envs'], + workspaceFolderValue: ['venvs', '.venv'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -316,14 +316,16 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Relative paths are not resolved against workspace folders (order doesn't matter) - const expected = new Set(['venvs', '../shared-envs']); - const actual = new Set(result); - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Relative paths are resolved against each workspace folder + // path.resolve behavior varies by platform, so check the paths contain expected segments + assert.strictEqual(result.length, 4, 'Should have 4 paths (2 relative Γ— 2 workspaces)'); + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('venvs'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('venvs'))); + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); }); - test('Relative paths without workspace folders are kept', async () => { + test('Relative paths without workspace folders logs warning', async () => { // Mock β†’ Relative paths but no workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); @@ -337,9 +339,9 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - assert.deepStrictEqual(result, ['relative-path']); - assert.strictEqual(mockTraceWarn.called, false, 'Should not warn when keeping relative paths'); + // Assert - Path is not added and warning is logged + assert.deepStrictEqual(result, []); + assert.ok(mockTraceWarn.called, 'Should warn about missing workspace folders'); }); test('Empty and whitespace paths are skipped', async () => { @@ -350,7 +352,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { globalValue: ['/valid/path', '', ' ', '/another/valid/path'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['valid-relative', '', ' \t\n ', 'another-valid'], + workspaceFolderValue: ['/workspace/valid', '', ' \t\n ', '/workspace/another'], }); const workspace = Uri.file('/workspace'); @@ -359,13 +361,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Now globalSearchPaths empty strings should be filtered out (order doesn't matter) - const expected = new Set([ - '/valid/path', - '/another/valid/path', - 'valid-relative', - 'another-valid', - ]); + // Assert - Empty strings filtered out, valid paths kept + const expected = new Set(['/valid/path', '/another/valid/path', '/workspace/valid', '/workspace/another']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -395,7 +392,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { globalValue: ['/legacy/venv/path', '/legacy/venvs', '/global/conda', '~/personal/envs'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['.venv', 'project-envs', '/shared/team/envs'], + workspaceFolderValue: ['.venv', '/shared/team/envs'], }); const workspace1 = Uri.file('/workspace/project1'); @@ -407,32 +404,27 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Should deduplicate and combine all sources (order doesn't matter) - const expected = new Set([ - '/legacy/venv/path', - '/legacy/venvs', - '/global/conda', - '/home/user/personal/envs', - '.venv', - 'project-envs', - '/shared/team/envs', - ]); - const actual = new Set(result); - - // Check that we have exactly the expected paths (no more, no less) - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Relative paths are resolved against workspace folders, absolutes kept as-is + // Expected: 4 from legacy/global + 1 absolute workspace path + 2 resolved .venv paths + assert.ok(result.includes('/legacy/venv/path')); + assert.ok(result.includes('/legacy/venvs')); + assert.ok(result.includes('/global/conda')); + assert.ok(result.includes('/home/user/personal/envs')); + assert.ok(result.includes('/shared/team/envs')); + // .venv resolved against both workspace folders + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); }); test('Overlapping paths are deduplicated', async () => { - // Mock β†’ Duplicate paths from different sources + // Mock β†’ Duplicate paths from different sources (using absolute paths to test dedup) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: ['/shared/path', '/global/unique'], }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['/shared/path', 'workspace-unique'], + workspaceFolderValue: ['/shared/path', '/workspace/unique'], }); const workspace = Uri.file('/workspace'); @@ -441,12 +433,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Duplicates should be removed (order doesn't matter) - const expected = new Set([ - '/shared/path', - '/global/unique', - 'workspace-unique', - ]); + // Assert - Duplicates should be removed + const expected = new Set(['/shared/path', '/global/unique', '/workspace/unique']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); @@ -458,7 +446,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { pythonConfig.get.withArgs('venvFolders').returns(['/legacy/folder']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: ['/global/path'] }); envConfig.inspect.withArgs('workspaceSearchPaths').returns({ - workspaceFolderValue: ['workspace-relative'], + workspaceFolderValue: ['.venv'], }); const workspace = Uri.file('/workspace'); @@ -467,16 +455,11 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - Should consolidate all path types - const expected = new Set([ - '/legacy/path', - '/legacy/folder', - '/global/path', - 'workspace-relative', - ]); - const actual = new Set(result); - assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); - assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + // Assert - Should consolidate all path types, relative resolved against workspace + assert.ok(result.includes('/legacy/path')); + assert.ok(result.includes('/legacy/folder')); + assert.ok(result.includes('/global/path')); + assert.ok(result.some((p) => p.includes('workspace') && p.endsWith('.venv'))); }); }); }); From 8f4a11d1251fbc4bbb80c0f854195b6e0d77b138 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:07:41 -0800 Subject: [PATCH 04/10] remove a lot of dead code and unneeded things --- README.md | 108 +++--- docs/search-paths-and-glob-patterns.md | 354 ------------------ package.json | 6 - package.nls.json | 1 - src/common/localize.ts | 16 - src/common/utils/pathUtils.ts | 18 - src/extension.ts | 41 +- src/features/views/envManagerSearch.ts | 156 +------- src/managers/common/nativePythonFinder.ts | 14 +- src/test/common/pathUtils.unit.test.ts | 86 +---- .../views/envManagerSearch.unit.test.ts | 65 ---- 11 files changed, 65 insertions(+), 800 deletions(-) delete mode 100644 docs/search-paths-and-glob-patterns.md delete mode 100644 src/test/features/views/envManagerSearch.unit.test.ts diff --git a/README.md b/README.md index fbc9a77c..2782b6ac 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,10 @@ The Python Environments extension for VS Code helps you manage Python environments and packages using your preferred environment manager, backed by its extensible APIs. This extension provides unique support for specifying environments for specific files, entire Python folders, or projects, including multi-root and mono-repo scenarios. The core feature set includes: -- 🌐 Create, delete, and manage environments -- πŸ“¦ Install and uninstall packages within the selected environment -- βœ… Create activated terminals -- πŸ–ŒοΈ Add and create new Python projects +- 🌐 Create, delete, and manage environments +- πŸ“¦ Install and uninstall packages within the selected environment +- βœ… Create activated terminals +- πŸ–ŒοΈ Add and create new Python projects > **Note:** This extension is in preview, and its APIs and features are subject to change as the project evolves. @@ -31,9 +31,9 @@ The Python Environments panel provides an interface to create, delete and manage To simplify the environment creation process, you can use "Quick Create" to automatically create a new virtual environment using: -- Your default environment manager (e.g., `venv`) -- The latest Python version -- Workspace dependencies +- Your default environment manager (e.g., `venv`) +- The latest Python version +- Workspace dependencies For more control, you can create a custom environment where you can specify Python version, environment name, packages to be installed, and more! @@ -61,9 +61,9 @@ The following environment managers are supported out of the box: **Legend:** -- **Create**: Ability to create new environments interactively. -- **Quick Create**: Ability to create environments with minimal user input. -- **Find Environments**: Ability to discover and list existing environments. +- **Create**: Ability to create new environments interactively. +- **Quick Create**: Ability to create environments with minimal user input. +- **Find Environments**: Ability to discover and list existing environments. Environment managers are responsible for specifying which package manager will be used by default to install and manage Python packages within the environment (`venv` uses `pip` by default). This ensures that packages are managed consistently according to the preferred tools and settings of the chosen environment manager. @@ -107,8 +107,8 @@ There are a few ways to add a Python Project from the Python Environments panel: The **Python Envs: Create New Project from Template** command simplifies the process of starting a new Python project by scaffolding it for you. Whether in a new workspace or an existing one, this command configures the environment and boilerplate file structure, so you don’t have to worry about the initial setup, and only the code you want to write. There are currently two project types supported: -- Package: A structured Python package with files like `__init__.py` and setup configurations. -- Script: A simple project for standalone Python scripts, ideal for quick tasks or just to get you started. +- Package: A structured Python package with files like `__init__.py` and setup configurations. +- Script: A simple project for standalone Python scripts, ideal for quick tasks or just to get you started. ## Command Reference @@ -127,31 +127,31 @@ All commands can be accessed via the Command Palette (`ctrl/cmd + Shift + P`): ### Python Environments Settings (`python-envs.`) -| Setting (python-envs.) | Default | Description | -| --------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| defaultEnvManager | `"ms-python.python:venv"` | The default environment manager used for creating and managing environments. | -| defaultPackageManager | `"ms-python.python:pip"` | The default package manager to use for installing and managing packages. This is often dictated by the default environment manager but can be customized. | -| 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. | -| globalSearchPaths | `[]` | Global search paths for Python environments. Array of absolute directory paths or glob patterns to search for environments at the user level. This setting is merged with the legacy `python.venvPath` and `python.venvFolders` settings. Supports glob patterns like `**/.venv` or `/path/*/envs`. See [Search Paths and Glob Patterns](docs/search-paths-and-glob-patterns.md) for detailed usage. | -| workspaceSearchPaths | `[]` | Workspace search paths for Python environments. Can be absolute paths, relative directory paths, or glob patterns searched within the workspace. Supports glob patterns like `**/.venv` or `tests/*/venv`. See [Search Paths and Glob Patterns](docs/search-paths-and-glob-patterns.md) for detailed usage. | +| Setting (python-envs.) | Default | Description | +| ---------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| defaultEnvManager | `"ms-python.python:venv"` | The default environment manager used for creating and managing environments. | +| defaultPackageManager | `"ms-python.python:pip"` | The default package manager to use for installing and managing packages. This is often dictated by the default environment manager but can be customized. | +| 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. | +| 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. | ### Supported Legacy Python Settings (`python.`) The following settings from the Python extension (`python.*`) are also supported by Python Environments. -| Setting (`python.`) | Default | Description | -| ---------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| condaPath | `""` | Path to the conda executable. Used to locate and run conda for environment discovery and management. | -| defaultInterpreterPath | `"python"` | Path to the default Python interpreter. | -| envFile | `"${workspaceFolder}/.env"` | Path to the environment file (`.env`) containing environment variable definitions. Used with `python.terminal.useEnvFile` to inject environment variables into terminals. | -| terminal.activateEnvironment | `true` | Legacy setting for terminal auto-activation. If `python-envs.terminal.autoActivationType` is not set and this is `false`, terminal auto-activation will be disabled. **Superseded by** `python-envs.terminal.autoActivationType` which takes precedence when configured. | -| terminal.executeInFileDir | `false` | When `true`, the terminal's working directory will be set to the directory containing the Python file being executed, rather than the project root directory. | -| terminal.useEnvFile | `false` | Controls whether environment variables from `.env` files (specified by `python.envFile`) are injected into terminals. | -| venvFolders | `[]` | Array of folder names to search for virtual environments. These folders are searched in addition to the standard locations. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | -| venvPath | `""` | Path to a folder containing virtual environments. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | +| Setting (`python.`) | Default | Description | +| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| condaPath | `""` | Path to the conda executable. Used to locate and run conda for environment discovery and management. | +| defaultInterpreterPath | `"python"` | Path to the default Python interpreter. | +| envFile | `"${workspaceFolder}/.env"` | Path to the environment file (`.env`) containing environment variable definitions. Used with `python.terminal.useEnvFile` to inject environment variables into terminals. | +| terminal.activateEnvironment | `true` | Legacy setting for terminal auto-activation. If `python-envs.terminal.autoActivationType` is not set and this is `false`, terminal auto-activation will be disabled. **Superseded by** `python-envs.terminal.autoActivationType` which takes precedence when configured. | +| terminal.executeInFileDir | `false` | When `true`, the terminal's working directory will be set to the directory containing the Python file being executed, rather than the project root directory. | +| terminal.useEnvFile | `false` | Controls whether environment variables from `.env` files (specified by `python.envFile`) are injected into terminals. | +| venvFolders | `[]` | Array of folder names to search for virtual environments. These folders are searched in addition to the standard locations. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | +| venvPath | `""` | Path to a folder containing virtual environments. **Note:** This setting is merged with `python-envs.globalSearchPaths`. Consider migrating to `python-envs.globalSearchPaths` for future compatibility. | ## Extensibility @@ -207,13 +207,13 @@ usage: `await vscode.commands.executeCommand('python-envs.createAny', options);` The Python Environments extension supports shell startup activation for environments. This feature allows you to automatically activate a Python environment when you open a terminal in VS Code. The activation is done by modifying the shell's startup script, which is supported for the following shells: -- **Bash**: `~/.bashrc` -- **Zsh**: `~/.zshrc` (or `$ZDOTDIR/.zshrc` if `ZDOTDIR` is set) -- **Fish**: `~/.config/fish/config.fish` -- **PowerShell**: - - (Mac/Linux):`~/.config/powershell/profile.ps1` - - (Windows): `~\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` -- **CMD**: `~/.cmdrc/cmd_startup.bat` +- **Bash**: `~/.bashrc` +- **Zsh**: `~/.zshrc` (or `$ZDOTDIR/.zshrc` if `ZDOTDIR` is set) +- **Fish**: `~/.config/fish/config.fish` +- **PowerShell**: + - (Mac/Linux):`~/.config/powershell/profile.ps1` + - (Windows): `~\Documents\PowerShell\Microsoft.PowerShell_profile.ps1` +- **CMD**: `~/.cmdrc/cmd_startup.bat` If at any time you would like to revert the changes made to the shell's script, you can do so by running `Python Envs: Revert Shell Startup Script Changes` via the Command Palette. @@ -310,11 +310,11 @@ This section provides an overview of how the Python extension interacts with the Tools that may rely on these APIs in their own extensions include: -- **Debuggers** (e.g., `debugpy`) -- **Linters** (e.g., Pylint, Flake8, Mypy) -- **Formatters** (e.g., Black, autopep8) -- **Language Server extensions** (e.g., Pylance, Jedi) -- **Environment and Package Manager extensions** (e.g., Pixi, Conda, Hatch) +- **Debuggers** (e.g., `debugpy`) +- **Linters** (e.g., Pylint, Flake8, Mypy) +- **Formatters** (e.g., Black, autopep8) +- **Language Server extensions** (e.g., Pylance, Jedi) +- **Environment and Package Manager extensions** (e.g., Pixi, Conda, Hatch) ### API Dependency @@ -334,12 +334,6 @@ The relationship is illustrated below: In **trusted mode**, the Python Environments extension supports tasks like managing environments, installing/removing packages, and running tools. In **untrusted mode**, functionality is limited to language features, ensuring a secure and restricted environment. -## Documentation - -- [Managing Python Projects](docs/managing-python-projects.md): Learn how to create and manage Python projects -- [Search Paths and Glob Patterns](docs/search-paths-and-glob-patterns.md): Customize where the extension searches for Python environments -- [Projects API Reference](docs/projects-api-reference.md): Technical reference for extension developers - ## Contributing This project welcomes contributions and suggestions. Most contributions require you to agree to a @@ -356,13 +350,13 @@ contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additio ## Questions, issues, feature requests, and contributions -- If you have a question about how to accomplish something with the extension, please [ask on our Discussions page](https://github.com/microsoft/vscode-python/discussions/categories/q-a). -- If you come across a problem with the extension, please [file an issue](https://github.com/microsoft/vscode-python). -- Contributions are always welcome! Please see our [contributing guide](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md) for more details. -- Any and all feedback is appreciated and welcome! - - If someone has already [filed an issue](https://github.com/Microsoft/vscode-python) that encompasses your feedback, please leave a πŸ‘/πŸ‘Ž reaction on the issue. - - Otherwise please start a [new discussion](https://github.com/microsoft/vscode-python/discussions/categories/ideas). -- If you're interested in the development of the extension, you can read about our [development process](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md#development-process). +- If you have a question about how to accomplish something with the extension, please [ask on our Discussions page](https://github.com/microsoft/vscode-python/discussions/categories/q-a). +- If you come across a problem with the extension, please [file an issue](https://github.com/microsoft/vscode-python). +- Contributions are always welcome! Please see our [contributing guide](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md) for more details. +- Any and all feedback is appreciated and welcome! + - If someone has already [filed an issue](https://github.com/Microsoft/vscode-python) that encompasses your feedback, please leave a πŸ‘/πŸ‘Ž reaction on the issue. + - Otherwise please start a [new discussion](https://github.com/microsoft/vscode-python/discussions/categories/ideas). +- If you're interested in the development of the extension, you can read about our [development process](https://github.com/Microsoft/vscode-python/blob/main/CONTRIBUTING.md#development-process). ## Data and telemetry diff --git a/docs/search-paths-and-glob-patterns.md b/docs/search-paths-and-glob-patterns.md deleted file mode 100644 index d6702bd9..00000000 --- a/docs/search-paths-and-glob-patterns.md +++ /dev/null @@ -1,354 +0,0 @@ -# Search Paths and Glob Patterns - -This guide explains how to configure where the Python Environments extension searches for Python environments using search paths and glob patterns. By the end, you'll understand how to effectively customize environment discovery to match your development workflow. - -## Overview - -By default, the Python Environments extension automatically discovers environments in well-known locations like the workspace folders, common virtual environment directories, and system Python installations. However, you can customize where the extension searches using two settings: - -- **`python-envs.globalSearchPaths`**: Global search paths applied to all workspaces -- **`python-envs.workspaceSearchPaths`**: Search paths specific to the current workspace - -Both settings support **glob patterns**, which allow you to specify flexible search patterns that match multiple directories. - -## When to Use Custom Search Paths - -Consider configuring custom search paths when: - -| Scenario | Example | -| ------------------------------- | -------------------------------------------------------------- | -| Centralized environment storage | All environments stored in `~/python-envs/` | -| Mono-repo structure | Multiple projects with nested `.venv` folders | -| Non-standard locations | Environments in `/opt/`, network drives, or custom directories | -| Team conventions | Standardized environment naming patterns | -| Testing scenarios | Temporary environments in test directories | - -## Configuring Search Paths - -### Global search paths - -Global search paths apply across all your VS Code workspaces. Use these for environment locations that are consistent across projects. - -1. Open Settings (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux). -2. Search for `python-envs.globalSearchPaths`. -3. Click **Add Item** to add a new path. -4. Enter an absolute path or glob pattern. - -Example configuration: - -```json -{ - "python-envs.globalSearchPaths": [ - "/Users/username/python-envs", - "/Users/username/projects/*/venv", - "/opt/python-environments/**" - ] -} -``` - -### Workspace search paths - -Workspace search paths apply only to the current workspace. Use these for project-specific environment locations. - -1. Open Settings (`Cmd+,` on macOS, `Ctrl+,` on Windows/Linux). -2. Switch to **Workspace** scope (not User). -3. Search for `python-envs.workspaceSearchPaths`. -4. Click **Add Item** to add a new path. -5. Enter a relative path (from workspace root) or absolute path. - -Example configuration: - -```json -{ - "python-envs.workspaceSearchPaths": [".venv", "tests/**/.venv", "services/*/env"] -} -``` - -> **Note**: Relative paths in `workspaceSearchPaths` are resolved from the workspace root directory. - -## Glob Pattern Syntax - -Glob patterns provide a flexible way to match multiple directories using wildcards. The extension supports standard glob syntax: - -### Basic wildcards - -| Pattern | Matches | Example | -| ------- | --------------------------------------------------------- | ------------------------------------------------------------------ | -| `*` | Any sequence of characters within a single path component | `envs/*` matches `envs/project1` but not `envs/nested/project2` | -| `**` | Any sequence of path components (recursive) | `projects/**/.venv` matches `.venv` at any depth under `projects/` | -| `?` | Any single character | `project?` matches `project1`, `projectA` | -| `[...]` | Any character inside the brackets | `project[0-9]` matches `project0` through `project9` | - -### Pattern examples - -```json -{ - "python-envs.globalSearchPaths": [ - // Specific directory (no wildcard needed) - "/Users/username/main-env", - - // All direct subdirectories of envs/ - "/Users/username/envs/*", - - // All .venv directories at any depth - "/Users/username/projects/**/.venv", - - // All venv directories at any depth - "/Users/username/projects/**/venv", - - // Numbered project directories - "/Users/username/project[0-9]", - - // Multiple levels with wildcards - "/Users/username/clients/*/projects/*/env" - ] -} -``` - -## How Glob Expansion Works - -When you specify a glob pattern, the extension: - -1. **Expands the pattern** to find all matching directories -2. **Filters to directories only** (files are ignored unless they're Python executables) -3. **Searches each directory** recursively for Python environments - -### Example expansion - -Given the pattern `/Users/username/projects/**/.venv`: - -``` -projects/ -β”œβ”€β”€ backend/ -β”‚ └── .venv/ ← Matches -β”œβ”€β”€ frontend/ -β”‚ └── scripts/ -β”‚ └── .venv/ ← Matches -└── ml-pipeline/ - β”œβ”€β”€ training/ - β”‚ └── .venv/ ← Matches - └── inference/ - └── .venv/ ← Matches -``` - -All four `.venv` directories are added to the search paths. - -## Performance Considerations - -⚠️ **Important**: Glob patterns can significantly impact discovery performance if used incorrectly. - -### What to avoid - -| Pattern | Problem | Impact | -| -------------------- | ------------------------------ | -------------------------- | -| `/**` | Searches the entire filesystem | Very slow, may time out | -| `/Users/username/**` | Searches all user files | Extremely slow | -| `path/to/project/**` | Lists every subdirectory | Redundant, slows discovery | - -### Best practices - -βœ… **DO**: Use specific patterns - -```json -{ - "python-envs.workspaceSearchPaths": [ - ".venv", // Root-level .venv - "tests/**/.venv", // .venv directories under tests/ - "services/*/env" // env directories one level under services/ - ] -} -``` - -❌ **DON'T**: Use overly broad patterns - -```json -{ - "python-envs.workspaceSearchPaths": [ - "**", // Every directory! Very slow - "/Users/username/**" // Entire home directory! Extremely slow - ] -} -``` - -### Understanding `**` vs. no pattern - -| Configuration | Behavior | -| ----------------------- | ------------------------------------------------------------------------------ | -| `"/path/to/project"` | βœ… Extension searches this directory recursively for environments | -| `"/path/to/project/**"` | ⚠️ Extension treats EVERY subdirectory as a separate search path (inefficient) | - -> **Tip**: In most cases, you don't need `**` alone. Just specify the root directory and let the extension search recursively. - -## Common Use Cases - -### Find all .venv directories in a mono-repo - -```json -{ - "python-envs.workspaceSearchPaths": ["**/.venv"] -} -``` - -This finds `.venv` directories at any depth without treating every subdirectory as a search path. - -### Centralized environment storage - -```json -{ - "python-envs.globalSearchPaths": ["/Users/username/python-environments/*"] -} -``` - -This searches all direct subdirectories of your centralized environment folder. - -### Team convention: environments named "env" or "venv" - -```json -{ - "python-envs.workspaceSearchPaths": ["**/env", "**/venv"] -} -``` - -### Multiple project structures - -```json -{ - "python-envs.workspaceSearchPaths": [ - ".venv", // Root workspace environment - "backend/.venv", // Backend service environment - "services/*/venv", // Service-specific environments - "tests/**/test-env" // Test environments at any depth - ] -} -``` - -### Development and testing environments - -```json -{ - "python-envs.globalSearchPaths": ["/opt/python/dev/*", "/opt/python/test/*", "/Users/username/temp/envs/*"] -} -``` - -## Integration with Legacy Settings - -The extension merges custom search paths with legacy Python extension settings for backward compatibility. - -### Settings that are merged - -| Legacy Setting | Equivalent Modern Setting | -| -------------------- | ------------------------------------------- | -| `python.venvPath` | Merged into `python-envs.globalSearchPaths` | -| `python.venvFolders` | Merged into `python-envs.globalSearchPaths` | - -If you have both configured, the extension combines all paths into one search list. - -### Migration example - -**Before** (legacy Python extension): - -```json -{ - "python.venvPath": "/Users/username/envs", - "python.venvFolders": ["venv", ".venv"] -} -``` - -**After** (modern Python Environments): - -```json -{ - "python-envs.globalSearchPaths": ["/Users/username/envs/*", "**/venv", "**/.venv"] -} -``` - -> **Note**: You can continue using legacy settings, but migrating to `python-envs.globalSearchPaths` provides more flexibility with glob patterns. - -## Troubleshooting - -### Environments not appearing - -If your environments aren't discovered: - -1. **Verify paths are absolute** (for global search paths) or relative to workspace root (for workspace search paths) -2. **Check path separators**: Use `/` even on Windows -3. **Test without glob patterns first**: Start with a simple directory path, then add patterns -4. **Check extension logs**: Open **Output** panel and select **Python Environments** to see discovery logs -5. **Verify directory exists**: Glob patterns that match nothing are silently ignored - -### Slow environment discovery - -If discovery is taking too long: - -1. **Review glob patterns**: Look for overly broad patterns like `**` or `/Users/**` -2. **Be more specific**: Replace `projects/**` with `projects/**/.venv` to target specific directories -3. **Reduce search paths**: Remove paths that don't contain environments -4. **Use root directories**: Instead of `path/**`, use `path` and let the extension search recursively - -### Duplicate environments - -If environments appear multiple times: - -1. **Check for overlapping paths**: Ensure patterns don't match the same directories -2. **Remove redundant patterns**: If you specify both `projects/` and `projects/**/.venv`, the latter is sufficient -3. **Review workspace vs. global settings**: Ensure you're not duplicating paths across scopes - -## Quick Reference: Settings - -| Setting | Scope | Description | -| ---------------------------------- | ----------------- | -------------------------------------------------------------------------- | -| `python-envs.globalSearchPaths` | User or Workspace | Array of absolute paths or glob patterns searched across all workspaces | -| `python-envs.workspaceSearchPaths` | Workspace | Array of relative or absolute paths searched in the current workspace only | -| `python.venvPath` | User or Workspace | Legacy setting merged into global search paths | -| `python.venvFolders` | User or Workspace | Legacy setting merged into global search paths | - -## Pattern Reference - -### Quick pattern guide - -```json -{ - "python-envs.globalSearchPaths": [ - "/absolute/path", // Specific directory - "/parent/*", // Direct children only - "/parent/**/target", // Target directories at any depth - "/parent/child[0-9]", // Numbered children - "/parent/child?", // Single character wildcard - "/parent/{option1,option2}/env" // Alternative branches (if supported) - ] -} -``` - -### Platform-specific examples - -**macOS/Linux**: - -```json -{ - "python-envs.globalSearchPaths": [ - "/opt/python-envs/*", - "~/.local/share/virtualenvs/*", - "/usr/local/python-environments/*" - ] -} -``` - -**Windows**: - -```json -{ - "python-envs.globalSearchPaths": [ - "C:/Python/Environments/*", - "C:/Users/username/python-envs/*", - "D:/Development/*/venv" - ] -} -``` - -> **Note**: Use forward slashes `/` even on Windows. - -## Related Resources - -- [Managing Python Projects](managing-python-projects.md): Learn how to organize projects with their own environments -- [Environment Management](../README.md#environment-management): Learn about creating and managing Python environments -- [Settings Reference](../README.md#settings-reference): Complete list of extension settings diff --git a/package.json b/package.json index 7ea7f1d7..9988dcaf 100644 --- a/package.json +++ b/package.json @@ -213,12 +213,6 @@ "category": "Python", "icon": "$(refresh)" }, - { - "command": "python-envs.managerSearch", - "title": "%python-envs.managerSearch.title%", - "category": "Python", - "icon": "$(search)" - }, { "command": "python-envs.searchSettings", "title": "%python-envs.searchSettings.title%", diff --git a/package.nls.json b/package.nls.json index 26b158ba..191925cf 100644 --- a/package.nls.json +++ b/package.nls.json @@ -31,7 +31,6 @@ "python-envs.setEnvSelected.title": "Set!", "python-envs.remove.title": "Delete Environment", "python-envs.refreshAllManagers.title": "Refresh All Environment Managers", - "python-envs.managerSearch.title": "Search Workspace for Environments", "python-envs.searchSettings.title": "Configure Search Settings", "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", diff --git a/src/common/localize.ts b/src/common/localize.ts index 34bf5b5b..7191a184 100644 --- a/src/common/localize.ts +++ b/src/common/localize.ts @@ -198,22 +198,6 @@ export namespace EnvViewStrings { export const selectedWorkspaceTooltip = l10n.t('This environment is selected for project files'); } -export namespace EnvManagerSearchStrings { - export const selectAction = l10n.t('Select an action'); - export const adjustSearchPaths = l10n.t('Adjust search path settings'); - export const adjustSearchPathsDescription = l10n.t('Open settings for environment search paths'); - export const fullWorkspaceSearch = l10n.t('Do full workspace search'); - export const fullWorkspaceSearchDescription = l10n.t('Search the entire workspace for environments'); - export const saveSearchPrompt = l10n.t('Save this search setting for future discovery?'); - export const dontShowAgain = l10n.t("Don't show again"); - export const slowLoadingMessage = l10n.t( - 'Environment discovery is taking longer than expected. This may be due to workspace search paths.', - ); - export const openSettings = l10n.t('Open Settings'); - export const removeWorkspaceSearch = l10n.t('Remove Workspace Search'); - export const dontShowForWorkspace = l10n.t("Don't Show for This Workspace"); -} - export namespace ActivationStrings { export const envCollectionDescription = l10n.t('Environment variables for shell activation'); export const revertedShellStartupScripts = l10n.t( diff --git a/src/common/utils/pathUtils.ts b/src/common/utils/pathUtils.ts index 948601c0..d398828a 100644 --- a/src/common/utils/pathUtils.ts +++ b/src/common/utils/pathUtils.ts @@ -65,24 +65,6 @@ export function normalizePath(fsPath: string): string { return path1; } -/** - * Normalizes a search path for comparison while preserving relative and glob strings. - * Absolute paths are resolved; relative/glob paths are trimmed and left intact. - */ -export function normalizePathKeepGlobs(value: string): string { - const trimmed = value.trim(); - if (!trimmed) { - return ''; - } - - if (path.isAbsolute(trimmed)) { - const resolved = path.resolve(trimmed); - return isWindows() ? resolved.toLowerCase() : resolved; - } - - return isWindows() ? trimmed.toLowerCase() : trimmed; -} - export function getResourceUri(resourcePath: string, root?: string): Uri | undefined { try { if (!resourcePath) { diff --git a/src/extension.ts b/src/extension.ts index d3813baf..3c1ad674 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -65,11 +65,7 @@ import { TerminalEnvVarInjector } from './features/terminal/terminalEnvVarInject import { TerminalManager, TerminalManagerImpl } from './features/terminal/terminalManager'; import { registerTerminalPackageWatcher } from './features/terminal/terminalPackageWatcher'; import { getEnvironmentForTerminal } from './features/terminal/utils'; -import { - handleEnvManagerSearchAction, - openSearchSettings, - startSlowLoadingMonitor, -} from './features/views/envManagerSearch'; +import { openSearchSettings } from './features/views/envManagerSearch'; import { EnvManagerView } from './features/views/envManagersView'; import { ProjectView } from './features/views/projectView'; import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; @@ -150,7 +146,6 @@ export async function activate(context: ExtensionContext): Promise(); - const nativeFinderDeferred = createDeferred(); const temporaryStateManager = new TemporaryStateManager(); context.subscriptions.push(temporaryStateManager); @@ -183,10 +178,6 @@ export async function activate(context: ExtensionContext): Promise { await Promise.all(envManagers.managers.map((m) => m.refresh(undefined))); }), - commands.registerCommand('python-envs.managerSearch', async () => { - const nativeFinder = await nativeFinderDeferred.promise; - await handleEnvManagerSearchAction(envManagers, nativeFinder); - }), commands.registerCommand('python-envs.searchSettings', async () => { await openSearchSettings(); }), @@ -457,31 +448,21 @@ export async function activate(context: ExtensionContext): Promise { - // Start monitoring for slow loading - will show notification if discovery takes too long - const cancelSlowLoadingMonitor = startSlowLoadingMonitor(); - // This is the finder that is used by all the built in environment managers const nativeFinder: NativePythonFinder = await createNativePythonFinder(outputChannel, api, context); - nativeFinderDeferred.resolve(nativeFinder); context.subscriptions.push(nativeFinder); const sysMgr = new SysPythonManager(nativeFinder, api, outputChannel); sysPythonManager.resolve(sysMgr); - - try { - await Promise.all([ - registerSystemPythonFeatures(nativeFinder, context.subscriptions, outputChannel, sysMgr), - registerCondaFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), - registerPyenvFeatures(nativeFinder, context.subscriptions, projectManager), - registerPipenvFeatures(nativeFinder, context.subscriptions, projectManager), - registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), - shellStartupVarsMgr.initialize(), - ]); - - await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api); - } finally { - // Cancel the slow loading monitor once initialization completes (or fails) - cancelSlowLoadingMonitor(); - } + await Promise.all([ + registerSystemPythonFeatures(nativeFinder, context.subscriptions, outputChannel, sysMgr), + registerCondaFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), + registerPyenvFeatures(nativeFinder, context.subscriptions, projectManager), + registerPipenvFeatures(nativeFinder, context.subscriptions, projectManager), + registerPoetryFeatures(nativeFinder, context.subscriptions, outputChannel, projectManager), + shellStartupVarsMgr.initialize(), + ]); + + await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api); // Register manager-agnostic terminal watcher for package-modifying commands registerTerminalPackageWatcher(api, terminalActivation, outputChannel, context.subscriptions); diff --git a/src/features/views/envManagerSearch.ts b/src/features/views/envManagerSearch.ts index 75f94f1f..3d40bea0 100644 --- a/src/features/views/envManagerSearch.ts +++ b/src/features/views/envManagerSearch.ts @@ -1,27 +1,4 @@ -import * as path from 'path'; -import { commands, ConfigurationTarget, window } from 'vscode'; -import { Common, EnvManagerSearchStrings } from '../../common/localize'; -import { traceLog } from '../../common/logging'; -import { getWorkspacePersistentState } from '../../common/persistentState'; -import { normalizePathKeepGlobs } from '../../common/utils/pathUtils'; -import { getConfiguration, getWorkspaceFolders } from '../../common/workspace.apis'; -import { EnvironmentManagers } from '../../internal.api'; -import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; - -const SUPPRESS_SAVE_PROMPT_KEY = 'python-envs.search.fullWorkspace.suppressSavePrompt'; -const SUPPRESS_SLOW_LOADING_KEY = 'python-envs.search.slowLoading.suppressPrompt'; -const SLOW_LOADING_THRESHOLD_MS = 10_000; // 10 seconds - -/** - * Handles the Environment Managers view search action. - * Performs a full workspace search for Python environments. - */ -export async function handleEnvManagerSearchAction( - envManagers: EnvironmentManagers, - nativeFinder: NativePythonFinder, -): Promise { - await runFullWorkspaceSearch(envManagers, nativeFinder); -} +import { commands } from 'vscode'; /** * Opens environment search settings at workspace level. @@ -32,134 +9,3 @@ export async function openSearchSettings(): Promise { '@ext:ms-python.vscode-python-envs "search path"', ); } - -/** - * Performs a recursive search for Python environments across all workspace folders. - * Uses the `./**` glob pattern to search the entire workspace tree. - * After the search completes, prompts the user to save the search pattern to settings. - */ -export async function runFullWorkspaceSearch( - envManagers: EnvironmentManagers, - nativeFinder: NativePythonFinder, -): Promise { - const workspaceFolders = getWorkspaceFolders(); - if (!workspaceFolders || workspaceFolders.length === 0) { - return; - } - - // Construct search paths for all workspace folders - const searchPaths = workspaceFolders.map((folder) => path.join(folder.uri.fsPath, '**')); - traceLog('Full workspace search:', searchPaths); - - nativeFinder.setTemporarySearchPaths(searchPaths); - try { - await Promise.all(envManagers.managers.map((manager) => manager.refresh(undefined))); - } finally { - nativeFinder.setTemporarySearchPaths(undefined); - } - - await promptToSaveSearchPaths(['./**']); -} - -/** - * Prompts the user to save the search paths to workspace settings. - * Respects the user's "Don't show again" preference stored in persistent state. - */ -async function promptToSaveSearchPaths(searchPaths: string[]): Promise { - const state = await getWorkspacePersistentState(); - const suppressPrompt = await state.get(SUPPRESS_SAVE_PROMPT_KEY, false); - if (suppressPrompt) { - return; - } - - const response = await window.showInformationMessage( - EnvManagerSearchStrings.saveSearchPrompt, - Common.yes, - Common.no, - EnvManagerSearchStrings.dontShowAgain, - ); - - if (response === EnvManagerSearchStrings.dontShowAgain) { - await state.set(SUPPRESS_SAVE_PROMPT_KEY, true); - return; - } - - if (response === Common.yes) { - await appendWorkspaceSearchPaths(searchPaths); - } -} - -/** - * Appends new search paths to the workspace-level `workspaceSearchPaths` setting. - * Deduplicates paths using case-insensitive comparison on Windows. - */ -export async function appendWorkspaceSearchPaths(searchPaths: string[]): Promise { - const config = getConfiguration('python-envs'); - const inspection = config.inspect('workspaceSearchPaths'); - const currentPaths = inspection?.workspaceValue ?? []; - const normalizedCurrent = new Set(currentPaths.map((value) => normalizePathKeepGlobs(value))); - const filteredSearchPaths = searchPaths.filter((value) => { - const normalized = normalizePathKeepGlobs(value); - return normalized && !normalizedCurrent.has(normalized); - }); - - if (filteredSearchPaths.length === 0) { - return; - } - - const nextPaths = [...currentPaths, ...filteredSearchPaths]; - await config.update('workspaceSearchPaths', nextPaths, ConfigurationTarget.Workspace); -} - -/** - * Clears the workspace-level `workspaceSearchPaths` setting. - */ -export async function clearWorkspaceSearchPaths(): Promise { - const config = getConfiguration('python-envs'); - await config.update('workspaceSearchPaths', [], ConfigurationTarget.Workspace); - traceLog('Cleared workspace search paths'); -} - -/** - * Monitors environment refresh and shows a notification if it takes too long. - * Returns a cleanup function to cancel the timeout if refresh completes early. - */ -export function startSlowLoadingMonitor(): () => void { - let timeoutId: NodeJS.Timeout | undefined; - let notificationShown = false; - - const showNotification = async (): Promise => { - const state = await getWorkspacePersistentState(); - const suppressNotification = await state.get(SUPPRESS_SLOW_LOADING_KEY, false); - if (suppressNotification || notificationShown) { - return; - } - notificationShown = true; - - const response = await window.showWarningMessage( - EnvManagerSearchStrings.slowLoadingMessage, - EnvManagerSearchStrings.openSettings, - EnvManagerSearchStrings.removeWorkspaceSearch, - EnvManagerSearchStrings.dontShowForWorkspace, - ); - - if (response === EnvManagerSearchStrings.openSettings) { - await openSearchSettings(); - } else if (response === EnvManagerSearchStrings.removeWorkspaceSearch) { - await clearWorkspaceSearchPaths(); - } else if (response === EnvManagerSearchStrings.dontShowForWorkspace) { - await state.set(SUPPRESS_SLOW_LOADING_KEY, true); - } - }; - - timeoutId = setTimeout(() => { - showNotification(); - }, SLOW_LOADING_THRESHOLD_MS); - - return () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = undefined; - } - }; -} diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index c336c4a2..5e78ae0f 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -103,11 +103,6 @@ export interface NativePythonFinder extends Disposable { * @param executable */ resolve(executable: string): Promise; - /** - * Sets temporary search paths used for the next discovery refresh. - * These paths are not persisted to user or workspace settings. - */ - setTemporarySearchPaths(searchPaths?: string[]): void; } interface NativeLog { level: string; @@ -161,7 +156,6 @@ class NativePythonFinderImpl implements NativePythonFinder { private startFailed: boolean = false; private restartAttempts: number = 0; private isRestarting: boolean = false; - private temporarySearchPaths: string[] | undefined; constructor( private readonly outputChannel: LogOutputChannel, @@ -203,10 +197,6 @@ class NativePythonFinderImpl implements NativePythonFinder { } } - public setTemporarySearchPaths(searchPaths?: string[]): void { - this.temporarySearchPaths = searchPaths?.filter((value) => value && value.trim() !== ''); - } - /** * Ensures the PET process is running. If it has exited or failed, attempts to restart * with exponential backoff up to MAX_RESTART_ATTEMPTS times. @@ -573,12 +563,10 @@ class NativePythonFinderImpl implements NativePythonFinder { private async configure() { // Get all extra search paths including legacy settings and new searchPaths const extraSearchPaths = await getAllExtraSearchPaths(); - const temporarySearchPaths = this.temporarySearchPaths ?? []; - const environmentDirectories = Array.from(new Set([...extraSearchPaths, ...temporarySearchPaths])); const options: ConfigurationOptions = { workspaceDirectories: this.api.getPythonProjects().map((item) => item.uri.fsPath), - environmentDirectories, + environmentDirectories: extraSearchPaths, condaExecutable: getPythonSettingAndUntildify('condaPath'), pipenvExecutable: getPythonSettingAndUntildify('pipenvPath'), poetryExecutable: getPythonSettingAndUntildify('poetryPath'), diff --git a/src/test/common/pathUtils.unit.test.ts b/src/test/common/pathUtils.unit.test.ts index 94a79372..1733e789 100644 --- a/src/test/common/pathUtils.unit.test.ts +++ b/src/test/common/pathUtils.unit.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; -import { getResourceUri, normalizePath, normalizePathKeepGlobs } from '../../common/utils/pathUtils'; +import { getResourceUri, normalizePath } from '../../common/utils/pathUtils'; import * as utils from '../../common/utils/platformUtils'; suite('Path Utilities', () => { @@ -128,88 +128,4 @@ suite('Path Utilities', () => { assert.strictEqual(result, 'C:/Path/To/File.txt'); }); }); - - suite('normalizePathKeepGlobs', () => { - teardown(() => { - sinon.restore(); - }); - - suite('on POSIX systems', () => { - setup(() => { - sinon.stub(utils, 'isWindows').returns(false); - }); - - test('returns empty string for empty input', () => { - assert.strictEqual(normalizePathKeepGlobs(''), ''); - }); - - test('returns empty string for whitespace-only input', () => { - assert.strictEqual(normalizePathKeepGlobs(' '), ''); - }); - - test('trims whitespace from relative paths', () => { - assert.strictEqual(normalizePathKeepGlobs(' .venv '), '.venv'); - }); - - test('preserves relative paths as-is', () => { - assert.strictEqual(normalizePathKeepGlobs('./**'), './**'); - assert.strictEqual(normalizePathKeepGlobs('envs/test'), 'envs/test'); - }); - - test('resolves absolute paths', () => { - const result = normalizePathKeepGlobs('/home/user/envs'); - assert.strictEqual(result, '/home/user/envs'); - }); - - test('preserves case for relative paths', () => { - assert.strictEqual(normalizePathKeepGlobs('MyEnv'), 'MyEnv'); - }); - - test('preserves case for absolute paths', () => { - const result = normalizePathKeepGlobs('/home/User/Envs'); - assert.ok(result.includes('User') || result.includes('user')); - }); - }); - - suite('on Windows systems', () => { - setup(() => { - sinon.stub(utils, 'isWindows').returns(true); - }); - - test('lowercases relative paths', () => { - assert.strictEqual(normalizePathKeepGlobs('.VENV'), '.venv'); - assert.strictEqual(normalizePathKeepGlobs('MyEnv'), 'myenv'); - }); - - test('lowercases absolute paths', () => { - // On Windows, path.resolve would handle the path - // The important part is that the result is lowercased - const result = normalizePathKeepGlobs('C:\\Users\\Test'); - assert.strictEqual(result, result.toLowerCase()); - }); - - test('handles glob patterns', () => { - assert.strictEqual(normalizePathKeepGlobs('./**'), './**'); - assert.strictEqual(normalizePathKeepGlobs('**/.venv'), '**/.venv'); - }); - }); - - suite('path normalization consistency', () => { - setup(() => { - sinon.stub(utils, 'isWindows').returns(false); - }); - - test('same paths normalize to same value', () => { - const path1 = normalizePathKeepGlobs('.venv'); - const path2 = normalizePathKeepGlobs(' .venv '); - assert.strictEqual(path1, path2); - }); - - test('different paths normalize differently', () => { - const path1 = normalizePathKeepGlobs('.venv'); - const path2 = normalizePathKeepGlobs('venv'); - assert.notStrictEqual(path1, path2); - }); - }); - }); }); diff --git a/src/test/features/views/envManagerSearch.unit.test.ts b/src/test/features/views/envManagerSearch.unit.test.ts deleted file mode 100644 index ff062389..00000000 --- a/src/test/features/views/envManagerSearch.unit.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert'; -import * as sinon from 'sinon'; -import { ConfigurationTarget, WorkspaceConfiguration } from 'vscode'; -import * as platformUtils from '../../../common/utils/platformUtils'; -import * as workspaceApis from '../../../common/workspace.apis'; -import { appendWorkspaceSearchPaths } from '../../../features/views/envManagerSearch'; - -type UpdateCall = { key: string; value: unknown; target?: ConfigurationTarget | boolean }; - -suite('Environment Manager Search', () => { - suite('appendWorkspaceSearchPaths', () => { - let updateCalls: UpdateCall[]; - - function createMockConfig(workspaceValue: string[]) { - updateCalls = []; - return { - inspect: sinon.stub().returns({ workspaceValue }), - update: sinon - .stub() - .callsFake((section: string, value: unknown, target?: ConfigurationTarget | boolean) => { - updateCalls.push({ key: section, value, target }); - return Promise.resolve(); - }), - } as unknown as WorkspaceConfiguration; - } - - teardown(() => { - sinon.restore(); - }); - - test('does not update when all paths are duplicates or empty', async () => { - sinon.stub(platformUtils, 'isWindows').returns(false); - const mockConfig = createMockConfig(['.venv', 'envs/existing']); - const getConfigurationStub = sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); - - await appendWorkspaceSearchPaths([' .venv ', ' ', 'envs/existing']); - - assert.strictEqual(getConfigurationStub.calledOnce, true); - assert.strictEqual(updateCalls.length, 0); - }); - - test('appends new paths to workspace search paths', async () => { - sinon.stub(platformUtils, 'isWindows').returns(false); - const mockConfig = createMockConfig(['.venv']); - sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); - - await appendWorkspaceSearchPaths(['envs/new', ' .venv ']); - - assert.strictEqual(updateCalls.length, 1); - assert.strictEqual(updateCalls[0].key, 'workspaceSearchPaths'); - assert.deepStrictEqual(updateCalls[0].value, ['.venv', 'envs/new']); - assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); - }); - - test('dedupes paths case-insensitively on Windows', async () => { - sinon.stub(platformUtils, 'isWindows').returns(true); - const mockConfig = createMockConfig(['ENV']); - sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); - - await appendWorkspaceSearchPaths(['env']); - - assert.strictEqual(updateCalls.length, 0); - }); - }); -}); From bb692f32450d905bc4f388f3c24948cfac255c69 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:26:38 -0800 Subject: [PATCH 05/10] path normalization --- src/managers/common/nativePythonFinder.ts | 7 +- ...Finder.getAllExtraSearchPaths.unit.test.ts | 81 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index 5e78ae0f..d2e77355 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -710,10 +710,11 @@ export async function getAllExtraSearchPaths(): Promise { } } - // Remove duplicates and return + // Remove duplicates and normalize to forward slashes for cross-platform glob compatibility const uniquePaths = Array.from(new Set(searchDirectories)); - traceVerbose('Environment search directories:', uniquePaths.length, 'paths'); - return uniquePaths; + const normalizedPaths = uniquePaths.map((p) => p.replace(/\\/g, '/')); + traceVerbose('Environment search directories:', normalizedPaths.length, 'paths'); + return normalizedPaths; } /** diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index dbe2ac4f..58175a14 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -462,4 +462,85 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.ok(result.some((p) => p.includes('workspace') && p.endsWith('.venv'))); }); }); + + suite('Cross-Platform Path Normalization', () => { + test('Backslashes are converted to forward slashes for glob compatibility', async () => { + // Mock β†’ Windows-style paths with backslashes + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\test\\envs'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\shared\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Python\\environments', 'E:\\projects\\**\\.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All backslashes should be converted to forward slashes + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + assert.ok(result.includes('C:/Users/test/envs')); + assert.ok(result.includes('D:/shared/venvs')); + assert.ok(result.includes('C:/Python/environments')); + assert.ok(result.includes('E:/projects/**/.venv')); + }); + + test('Glob patterns with backslashes are normalized', async () => { + // Mock β†’ Glob pattern with Windows backslashes + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\workspace\\**\\venv', 'D:\\projects\\*\\.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Glob patterns should use forward slashes + assert.ok(result.includes('C:/workspace/**/venv')); + assert.ok(result.includes('D:/projects/*/.venv')); + }); + + test('Linux/macOS paths with forward slashes are preserved', async () => { + // Mock β†’ Unix-style paths (already using forward slashes) + pythonConfig.get.withArgs('venvPath').returns('/home/user/envs'); + pythonConfig.get.withArgs('venvFolders').returns(['/opt/shared/venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['/usr/local/python/environments', '/home/user/projects/**/.venv'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Forward slashes should be preserved as-is + assert.ok(result.includes('/home/user/envs')); + assert.ok(result.includes('/opt/shared/venvs')); + assert.ok(result.includes('/usr/local/python/environments')); + assert.ok(result.includes('/home/user/projects/**/.venv')); + // Verify no backslashes were introduced + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + }); + + test('Mixed path separators are normalized to forward slashes', async () => { + // Mock β†’ Paths with mixed separators (edge case) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:/Users\\test/projects\\.venv', '/home/user\\mixed/path'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All backslashes normalized to forward slashes + assert.ok(result.includes('C:/Users/test/projects/.venv')); + assert.ok(result.includes('/home/user/mixed/path')); + }); + }); }); From 2e8e6c3ac22d00c50ac4da75ff1281928539b788 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:55:17 -0800 Subject: [PATCH 06/10] lean windows --- ...Finder.getAllExtraSearchPaths.unit.test.ts | 191 ++++++++++++++++-- 1 file changed, 175 insertions(+), 16 deletions(-) diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index 58175a14..dbdfa0f5 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -31,6 +31,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { mockGetWorkspaceFolders = sinon.stub(workspaceApis, 'getWorkspaceFolders'); mockUntildify = sinon.stub(pathUtils, 'untildify'); // Also stub the namespace import version that might be used by untildifyArray + // Handle both Unix (~/) and Windows-style paths sinon .stub(pathUtils, 'untildifyArray') .callsFake((paths: string[]) => @@ -103,8 +104,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(result, []); }); - test('Legacy and global paths are consolidated', async () => { - // Mock β†’ Legacy paths and globalSearchPaths both exist + test('Legacy and global paths are consolidated (Unix)', async () => { + // Mock β†’ Legacy paths and globalSearchPaths both exist (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); pythonConfig.get.withArgs('venvFolders').returns(['/home/user/venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -122,8 +123,27 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Legacy paths included alongside new settings', async () => { - // Mock β†’ Legacy paths exist, no globalSearchPaths + test('Legacy and global paths are consolidated (Windows)', async () => { + // Mock β†’ Legacy paths and globalSearchPaths both exist (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\dev\\.virtualenvs'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\shared\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Users\\dev\\.virtualenvs', 'D:\\shared\\venvs', 'E:\\additional\\path'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Should consolidate all paths (duplicates removed), normalized to forward slashes + const expected = new Set(['C:/Users/dev/.virtualenvs', 'D:/shared/venvs', 'E:/additional/path']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Legacy paths included alongside new settings (Unix)', async () => { + // Mock β†’ Legacy paths exist, no globalSearchPaths (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); pythonConfig.get.withArgs('venvFolders').returns(['/home/user/venvs', '/home/user/conda']); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); @@ -139,6 +159,23 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); + test('Legacy paths included alongside new settings (Windows)', async () => { + // Mock β†’ Legacy paths exist, no globalSearchPaths (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\Users\\dev\\.virtualenvs'); + pythonConfig.get.withArgs('venvFolders').returns(['C:\\Users\\dev\\venvs', 'D:\\conda\\envs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Should include all legacy paths, normalized to forward slashes + const expected = new Set(['C:/Users/dev/.virtualenvs', 'C:/Users/dev/venvs', 'D:/conda/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + test('Legacy and global paths combined with deduplication', async () => { // Mock β†’ Some overlap between legacy and global paths pythonConfig.get.withArgs('venvPath').returns('/home/user/.virtualenvs'); @@ -184,8 +221,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); suite('Configuration Source Tests', () => { - test('Global search paths with tilde expansion', async () => { - // Mock β†’ No legacy, global paths with tildes + test('Global search paths with tilde expansion (Unix)', async () => { + // Mock β†’ No legacy, global paths with tildes (Unix ~ expansion) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -206,8 +243,27 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); - test('Workspace folder setting preferred over workspace setting', async () => { - // Mock β†’ Workspace settings at different levels + test('Global search paths with absolute paths (Windows)', async () => { + // Mock β†’ No legacy, global paths with Windows absolute paths + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\Users\\dev\\virtualenvs', 'D:\\conda\\envs'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({}); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Paths normalized to forward slashes + const expected = new Set(['C:/Users/dev/virtualenvs', 'D:/conda/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + + test('Workspace folder setting preferred over workspace setting (Unix)', async () => { + // Mock β†’ Workspace settings at different levels (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); @@ -230,6 +286,30 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); + test('Workspace folder setting preferred over workspace setting (Windows)', async () => { + // Mock β†’ Workspace settings at different levels (Windows-style) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceValue: ['D:\\workspace-level'], + workspaceFolderValue: ['C:\\folder-level\\path'], + }); + + const workspace1 = Uri.file('C:\\Projects\\project1'); + const workspace2 = Uri.file('C:\\Projects\\project2'); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - workspaceFolderValue takes priority, normalized to forward slashes + const expected = new Set(['C:/folder-level/path']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + test('Global workspace setting logs error and is ignored', async () => { // Mock β†’ Workspace setting incorrectly set at global level pythonConfig.get.withArgs('venvPath').returns(undefined); @@ -276,8 +356,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); suite('Path Resolution Tests', () => { - test('Absolute paths used as-is', async () => { - // Mock β†’ Mix of absolute paths + test('Absolute paths used as-is (Unix)', async () => { + // Mock β†’ Mix of absolute paths (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -293,13 +373,37 @@ suite('getAllExtraSearchPaths Integration Tests', () => { // Run const result = await getAllExtraSearchPaths(); - // Assert - For absolute paths, they should remain unchanged regardless of platform + // Assert - For absolute paths, they should remain unchanged const expected = new Set(['/absolute/path1', '/absolute/path2', '/absolute/workspace/path']); const actual = new Set(result); assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); + test('Absolute paths used as-is (Windows)', async () => { + // Mock β†’ Mix of absolute paths (Windows-style) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\absolute\\path1', 'D:\\absolute\\path2'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['E:\\workspace\\envs'], + }); + + const workspace = Uri.file('C:\\workspace'); + mockGetWorkspaceFolders.returns([{ uri: workspace }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Windows paths normalized to forward slashes + const expected = new Set(['C:/absolute/path1', 'D:/absolute/path2', 'E:/workspace/envs']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + test('Relative paths are resolved against workspace folders', async () => { // Mock β†’ Relative workspace paths with multiple workspace folders pythonConfig.get.withArgs('venvPath').returns(undefined); @@ -384,8 +488,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(result, []); }); - test('Power user - complex mix of all source types', async () => { - // Mock β†’ Complex real-world scenario + test('Power user - complex mix of all source types (Unix)', async () => { + // Mock β†’ Complex real-world scenario (Unix-style) pythonConfig.get.withArgs('venvPath').returns('/legacy/venv/path'); pythonConfig.get.withArgs('venvFolders').returns(['/legacy/venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -405,7 +509,6 @@ suite('getAllExtraSearchPaths Integration Tests', () => { const result = await getAllExtraSearchPaths(); // Assert - Relative paths are resolved against workspace folders, absolutes kept as-is - // Expected: 4 from legacy/global + 1 absolute workspace path + 2 resolved .venv paths assert.ok(result.includes('/legacy/venv/path')); assert.ok(result.includes('/legacy/venvs')); assert.ok(result.includes('/global/conda')); @@ -416,8 +519,40 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); }); - test('Overlapping paths are deduplicated', async () => { - // Mock β†’ Duplicate paths from different sources (using absolute paths to test dedup) + test('Power user - complex mix of all source types (Windows)', async () => { + // Mock β†’ Complex real-world scenario (Windows-style) + pythonConfig.get.withArgs('venvPath').returns('C:\\legacy\\venv\\path'); + pythonConfig.get.withArgs('venvFolders').returns(['D:\\legacy\\venvs']); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\legacy\\venv\\path', 'D:\\legacy\\venvs', 'E:\\global\\conda'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['.venv', 'F:\\shared\\team\\envs'], + }); + + const workspace1 = Uri.file('C:\\workspace\\project1'); + const workspace2 = Uri.file('C:\\workspace\\project2'); + mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - All paths normalized to forward slashes + assert.ok(result.includes('C:/legacy/venv/path')); + assert.ok(result.includes('D:/legacy/venvs')); + assert.ok(result.includes('E:/global/conda')); + assert.ok(result.includes('F:/shared/team/envs')); + // .venv resolved against both workspace folders + assert.ok(result.some((p) => p.includes('project1') && p.endsWith('.venv'))); + assert.ok(result.some((p) => p.includes('project2') && p.endsWith('.venv'))); + // Verify no backslashes remain + for (const p of result) { + assert.ok(!p.includes('\\'), `Path should not contain backslashes: ${p}`); + } + }); + + test('Overlapping paths are deduplicated (Unix)', async () => { + // Mock β†’ Duplicate paths from different sources (Unix-style) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -440,6 +575,30 @@ suite('getAllExtraSearchPaths Integration Tests', () => { assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); }); + test('Overlapping paths are deduplicated (Windows)', async () => { + // Mock β†’ Duplicate paths from different sources (Windows-style) + pythonConfig.get.withArgs('venvPath').returns(undefined); + pythonConfig.get.withArgs('venvFolders').returns(undefined); + envConfig.inspect.withArgs('globalSearchPaths').returns({ + globalValue: ['C:\\shared\\path', 'D:\\global\\unique'], + }); + envConfig.inspect.withArgs('workspaceSearchPaths').returns({ + workspaceFolderValue: ['C:\\shared\\path', 'E:\\workspace\\unique'], + }); + + const workspace = Uri.file('C:\\workspace'); + mockGetWorkspaceFolders.returns([{ uri: workspace }]); + + // Run + const result = await getAllExtraSearchPaths(); + + // Assert - Duplicates should be removed, normalized to forward slashes + const expected = new Set(['C:/shared/path', 'D:/global/unique', 'E:/workspace/unique']); + const actual = new Set(result); + assert.strictEqual(actual.size, expected.size, 'Should have correct number of unique paths'); + assert.deepStrictEqual(actual, expected, 'Should contain exactly the expected paths'); + }); + test('All path types consolidated together', async () => { // Mock β†’ Multiple path types from different sources pythonConfig.get.withArgs('venvPath').returns('/legacy/path'); From 0f440b0a562c2420304b0ce3b26abd51c4655ec3 Mon Sep 17 00:00:00 2001 From: Eleanor Boyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:12:41 -0800 Subject: [PATCH 07/10] Update package.nls.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- package.nls.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.nls.json b/package.nls.json index 191925cf..7aaeb349 100644 --- a/package.nls.json +++ b/package.nls.json @@ -12,7 +12,7 @@ "python-envs.terminal.autoActivationType.off": "No automatic activation of environments.", "python-envs.terminal.useEnvFile.description": "Controls whether environment variables from .env files and python.envFile setting are injected into terminals.", "python-envs.globalSearchPaths.description": "Absolute paths to search for Python environments across all workspaces. Use for shared environment folders like `~/envs`.", - "python-envs.workspaceSearchPaths.description": "Paths to search for environments in this workspace. Defaults to `./**/.venv` to find all `.venv` folders nested within your workspace.", + "python-envs.workspaceSearchPaths.description": "Paths to search for environments in this workspace. By default, searches for a `.venv` folder in the workspace root.", "python-envs.terminal.revertStartupScriptChanges.title": "Revert Shell Startup Script Changes", "python-envs.reportIssue.title": "Report Issue", "python-envs.setEnvManager.title": "Set Environment Manager", From 2a93543b68e65a7c29774b241cfaab5a9ebec607 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:15:48 -0800 Subject: [PATCH 08/10] fixes --- package.json | 2 +- src/extension.ts | 8 +++++-- src/managers/common/nativePythonFinder.ts | 7 +++++- ...Finder.getAllExtraSearchPaths.unit.test.ts | 24 +++++++++++-------- 4 files changed, 27 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 9988dcaf..16709c6d 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "type": "string", "description": "%python-envs.defaultEnvManager.description%", "default": "ms-python.python:venv", - "scope": "application" + "scope": "window" }, "python-envs.defaultPackageManager": { "type": "string", diff --git a/src/extension.ts b/src/extension.ts index 3c1ad674..bc5b0263 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,6 @@ -import { commands, ExtensionContext, LogOutputChannel, Terminal, Uri, window } from 'vscode'; +import { commands, ExtensionContext, extensions, LogOutputChannel, Terminal, Uri, window } from 'vscode'; import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api'; +import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; import { clearPersistentState, setPersistentState } from './common/persistentState'; @@ -101,7 +102,10 @@ export async function activate(context: ExtensionContext): Promise(name: string, scope?: Uri): T | undefin /** * Gets all extra environment search paths from various configuration sources. * Combines legacy python settings (with migration), globalSearchPaths, and workspaceSearchPaths. - * @returns Array of search directory paths + * + * Paths can include glob patterns which are expanded by the native + * Python Environment Tool (PET) during environment discovery. + * + * @returns Array of search paths (may include glob patterns) */ export async function getAllExtraSearchPaths(): Promise { const searchDirectories: string[] = []; @@ -736,6 +740,7 @@ function getGlobalSearchPaths(): string[] { /** * Gets the most specific workspace-level setting available for workspaceSearchPaths. + * Supports glob patterns which are expanded by PET. */ function getWorkspaceSearchPaths(): string[] { try { diff --git a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts index dbdfa0f5..43a7fe63 100644 --- a/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts +++ b/src/test/managers/common/nativePythonFinder.getAllExtraSearchPaths.unit.test.ts @@ -287,7 +287,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); test('Workspace folder setting preferred over workspace setting (Windows)', async () => { - // Mock β†’ Workspace settings at different levels (Windows-style) + // Mock β†’ Workspace settings at different levels (Windows-style paths in config) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ globalValue: [] }); @@ -296,8 +296,9 @@ suite('getAllExtraSearchPaths Integration Tests', () => { workspaceFolderValue: ['C:\\folder-level\\path'], }); - const workspace1 = Uri.file('C:\\Projects\\project1'); - const workspace2 = Uri.file('C:\\Projects\\project2'); + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace1 = Uri.file('/projects/project1'); + const workspace2 = Uri.file('/projects/project2'); mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); // Run @@ -381,7 +382,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); test('Absolute paths used as-is (Windows)', async () => { - // Mock β†’ Mix of absolute paths (Windows-style) + // Mock β†’ Mix of absolute paths (Windows-style paths in config) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -391,7 +392,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { workspaceFolderValue: ['E:\\workspace\\envs'], }); - const workspace = Uri.file('C:\\workspace'); + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace = Uri.file('/workspace'); mockGetWorkspaceFolders.returns([{ uri: workspace }]); // Run @@ -520,7 +522,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); test('Power user - complex mix of all source types (Windows)', async () => { - // Mock β†’ Complex real-world scenario (Windows-style) + // Mock β†’ Complex real-world scenario (Windows-style paths in config) pythonConfig.get.withArgs('venvPath').returns('C:\\legacy\\venv\\path'); pythonConfig.get.withArgs('venvFolders').returns(['D:\\legacy\\venvs']); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -530,8 +532,9 @@ suite('getAllExtraSearchPaths Integration Tests', () => { workspaceFolderValue: ['.venv', 'F:\\shared\\team\\envs'], }); - const workspace1 = Uri.file('C:\\workspace\\project1'); - const workspace2 = Uri.file('C:\\workspace\\project2'); + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace1 = Uri.file('/workspace/project1'); + const workspace2 = Uri.file('/workspace/project2'); mockGetWorkspaceFolders.returns([{ uri: workspace1 }, { uri: workspace2 }]); // Run @@ -576,7 +579,7 @@ suite('getAllExtraSearchPaths Integration Tests', () => { }); test('Overlapping paths are deduplicated (Windows)', async () => { - // Mock β†’ Duplicate paths from different sources (Windows-style) + // Mock β†’ Duplicate paths from different sources (Windows-style paths in config) pythonConfig.get.withArgs('venvPath').returns(undefined); pythonConfig.get.withArgs('venvFolders').returns(undefined); envConfig.inspect.withArgs('globalSearchPaths').returns({ @@ -586,7 +589,8 @@ suite('getAllExtraSearchPaths Integration Tests', () => { workspaceFolderValue: ['C:\\shared\\path', 'E:\\workspace\\unique'], }); - const workspace = Uri.file('C:\\workspace'); + // Use Unix-style URIs for workspace folders (Uri.file behavior is OS-dependent) + const workspace = Uri.file('/workspace'); mockGetWorkspaceFolders.returns([{ uri: workspace }]); // Run From 34b4a9aa79cd6b00d3ec221699ffe70696a46a55 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Thu, 5 Feb 2026 20:29:04 -0800 Subject: [PATCH 09/10] paths --- src/managers/common/nativePythonFinder.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/managers/common/nativePythonFinder.ts b/src/managers/common/nativePythonFinder.ts index 6fe267f8..23efc347 100644 --- a/src/managers/common/nativePythonFinder.ts +++ b/src/managers/common/nativePythonFinder.ts @@ -671,6 +671,15 @@ function getPythonSettingAndUntildify(name: string, scope?: Uri): T | undefin return value; } +/** + * Cross-platform check for absolute paths. + * Uses both current platform's check and Windows-specific check to handle + * Windows paths (e.g., C:\path) when running on Unix systems. + */ +function isAbsolutePath(inputPath: string): boolean { + return path.isAbsolute(inputPath) || path.win32.isAbsolute(inputPath); +} + /** * Gets all extra environment search paths from various configuration sources. * Combines legacy python settings (with migration), globalSearchPaths, and workspaceSearchPaths. @@ -702,7 +711,7 @@ export async function getAllExtraSearchPaths(): Promise { const trimmedPath = searchPath.trim(); - if (path.isAbsolute(trimmedPath)) { + if (isAbsolutePath(trimmedPath)) { // Absolute path - use as is searchDirectories.push(trimmedPath); } else { From 613b1c4f116526200ac6652277e972f4c28c19bd Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:53:02 -0800 Subject: [PATCH 10/10] bug: project initialization to load existing projects without overwriting user settings --- .gitignore | 5 +- src/features/projectManager.ts | 18 +- .../projectManager.initialize.unit.test.ts | 675 ++++++++++++++++++ 3 files changed, 696 insertions(+), 2 deletions(-) create mode 100644 src/test/features/projectManager.initialize.unit.test.ts diff --git a/.gitignore b/.gitignore index 0ed48310..b9494517 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ node_modules *.vsix .nox/ .venv/ -**/__pycache__/ \ No newline at end of file +**/__pycache__/ + +# Folder for storing AI generated artifacts +ai-artifacts/* \ No newline at end of file diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index ebf207b4..3d2f9d02 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -33,7 +33,9 @@ export class PythonProjectManagerImpl implements PythonProjectManager { private readonly updateDebounce = createSimpleDebounce(100, () => this.updateProjects()); initialize(): void { - this.add(this.getInitialProjects()); + // Load existing projects from settings without writing back to settings. + // This avoids overwriting user-configured project settings with defaults on reload. + this.loadProjects(this.getInitialProjects()); this.disposables.push( this._onDidChangeProjects, new Disposable(() => this._projects.clear()), @@ -175,6 +177,20 @@ export class PythonProjectManagerImpl implements PythonProjectManager { } } + /** + * Loads projects into the internal map without writing to settings. + * Use this for initial loading from existing settings to avoid overwriting + * user-configured project settings with defaults. + */ + private loadProjects(projects: ProjectArray): void { + projects.forEach((project) => { + this._projects.set(project.uri.toString(), project); + }); + if (projects.length > 0) { + this._onDidChangeProjects.fire(Array.from(this._projects.values())); + } + } + create( name: string, uri: Uri, diff --git a/src/test/features/projectManager.initialize.unit.test.ts b/src/test/features/projectManager.initialize.unit.test.ts new file mode 100644 index 00000000..545e22ea --- /dev/null +++ b/src/test/features/projectManager.initialize.unit.test.ts @@ -0,0 +1,675 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { Disposable, EventEmitter, Uri, WorkspaceFolder } from 'vscode'; +import * as workspaceApis from '../../common/workspace.apis'; +import { PythonProjectManagerImpl } from '../../features/projectManager'; +import * as settingHelpers from '../../features/settings/settingHelpers'; +import { PythonProjectSettings } from '../../internal.api'; +import { MockWorkspaceConfiguration } from '../mocks/mockWorkspaceConfig'; + +/** + * Returns a platform-appropriate workspace path for testing. + * On Windows, paths must include a drive letter to work correctly with path.resolve(). + */ +function getTestWorkspacePath(): string { + return process.platform === 'win32' ? 'C:\\workspace' : '/workspace'; +} + +/** + * ============================================================================= + * CRITICAL PRINCIPLE: Settings should ONLY change when user explicitly acts + * ============================================================================= + * + * These tests verify that the extension does NOT write to settings.json unless + * the user explicitly performs an action (like selecting an interpreter via UI). + * + * Scenarios that should NOT write settings: + * - Extension initialization/reload + * - Configuration changes made externally (user edits settings.json directly) + * - Workspace folder changes (user adds/removes folders) + * - Multiple reload cycles + * - Any getter operations (getProjects, get, etc.) + * + * Scenarios that SHOULD write settings: + * - User explicitly adds a project via UI + * - User explicitly selects an interpreter via picker + * - User explicitly changes env/package manager via command + * - Project folder deleted (cleanup orphan settings) + * - Project folder renamed (update path in settings) + */ + +suite('Project Manager Initialization - Settings Preservation', () => { + let disposables: Disposable[]; + let workspaceFoldersChangeEmitter: EventEmitter; + let configChangeEmitter: EventEmitter; + let deleteFilesEmitter: EventEmitter<{ files: readonly Uri[] }>; + let renameFilesEmitter: EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>; + let addPythonProjectSettingStub: sinon.SinonStub; + let setAllManagerSettingsStub: sinon.SinonStub; + let setEnvironmentManagerStub: sinon.SinonStub; + let setPackageManagerStub: sinon.SinonStub; + let clock: sinon.SinonFakeTimers; + + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'workspace', + index: 0, + }; + + setup(() => { + disposables = []; + clock = sinon.useFakeTimers(); + + // Create event emitters + workspaceFoldersChangeEmitter = new EventEmitter(); + configChangeEmitter = new EventEmitter(); + deleteFilesEmitter = new EventEmitter<{ files: readonly Uri[] }>(); + renameFilesEmitter = new EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>(); + disposables.push(workspaceFoldersChangeEmitter, configChangeEmitter, deleteFilesEmitter, renameFilesEmitter); + + // Stub workspace events + sinon.stub(workspaceApis, 'onDidChangeWorkspaceFolders').callsFake((listener: any) => { + return workspaceFoldersChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener: any) => { + return configChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidDeleteFiles').callsFake((listener: any) => { + return deleteFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidRenameFiles').callsFake((listener: any) => { + return renameFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + + // Stub ALL setting write functions to track any settings writes + addPythonProjectSettingStub = sinon.stub(settingHelpers, 'addPythonProjectSetting').resolves(); + setAllManagerSettingsStub = sinon.stub(settingHelpers, 'setAllManagerSettings').resolves(); + setEnvironmentManagerStub = sinon.stub(settingHelpers, 'setEnvironmentManager').resolves(); + setPackageManagerStub = sinon.stub(settingHelpers, 'setPackageManager').resolves(); + sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves(); + sinon.stub(settingHelpers, 'updatePythonProjectSettingPath').resolves(); + }); + + teardown(() => { + clock.restore(); + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + /** + * Helper to assert NO settings were written by any method + */ + function assertNoSettingsWritten(context: string): void { + assert.ok(!addPythonProjectSettingStub.called, `${context}: addPythonProjectSetting should NOT be called`); + assert.ok(!setAllManagerSettingsStub.called, `${context}: setAllManagerSettings should NOT be called`); + assert.ok(!setEnvironmentManagerStub.called, `${context}: setEnvironmentManager should NOT be called`); + assert.ok(!setPackageManagerStub.called, `${context}: setPackageManager should NOT be called`); + } + + /** + * Creates a mock config where: + * - pythonProjects has explicit venv/pip settings for subprojects + * - defaultEnvManager differs from project settings (conda vs venv) + * This tests that project-specific settings are preserved. + */ + function createMockConfigWithExplicitProjectSettings(): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + // These are existing project settings that should NOT be overwritten + return [ + { path: 'alice', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'alice/bob', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'ada', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + ] as unknown as T; + } + if (key === 'defaultEnvManager') { + // User changed this to conda + return 'ms-python.python:conda' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:conda' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + return mockConfig; + } + + suite('initialize() - No Settings Writes', () => { + test('initialize() should NOT call add() method', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + + // Spy on the add method - it should NOT be called during initialize() + const addSpy = sinon.spy(pm, 'add'); + + pm.initialize(); + + // Allow any async operations to complete + await clock.tickAsync(150); + + // CRITICAL: initialize() should NOT call add() - it should only load projects into memory + assert.ok( + !addSpy.called, + 'initialize() should NOT call add() - calling add() would write to settings and overwrite user config', + ); + + pm.dispose(); + }); + + test('initialize() should NOT call addPythonProjectSetting', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Allow any async operations to complete + await clock.tickAsync(150); + + // CRITICAL: initialize() should NOT write to settings + assert.ok( + !addPythonProjectSettingStub.called, + 'initialize() should NOT call addPythonProjectSetting - it should only load projects into memory', + ); + + pm.dispose(); + }); + + test('initialize() should load projects from settings without modifying them', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + // Verify projects are loaded + const projects = pm.getProjects(); + + // Should have workspace root + 3 explicit projects + assert.strictEqual(projects.length, 4, 'Should load workspace root + 3 explicit projects'); + + // Verify the subprojects exist + const aliceProject = projects.find((p) => p.uri.fsPath.endsWith('alice') && !p.uri.fsPath.includes('bob')); + const bobProject = projects.find( + (p) => p.uri.fsPath.includes('alice/bob') || p.uri.fsPath.includes('alice\\bob'), + ); + const adaProject = projects.find((p) => p.uri.fsPath.endsWith('ada')); + + assert.ok(aliceProject, 'alice project should be loaded'); + assert.ok(bobProject, 'alice/bob project should be loaded'); + assert.ok(adaProject, 'ada project should be loaded'); + + pm.dispose(); + }); + + test('project-specific settings should be preserved when defaultEnvManager differs', async () => { + // Scenario: + // 1. User has projects with explicit venv/pip settings + // 2. defaultEnvManager is set to conda + // 3. On reload, the explicit venv/pip settings should remain unchanged + + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + await clock.tickAsync(150); + + // initialize() should load projects without overwriting their explicit settings + assert.ok( + !addPythonProjectSettingStub.called, + 'initialize() should NOT overwrite explicit project settings with defaults', + ); + + pm.dispose(); + }); + }); + + suite('Configuration Changes - No Settings Writes', () => { + test('external settings.json changes should NOT trigger settings writes', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs to track only post-init calls + addPythonProjectSettingStub.resetHistory(); + setAllManagerSettingsStub.resetHistory(); + + // Simulate external configuration change (user edits settings.json) + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => + section === 'python-envs.pythonProjects' || section === 'python-envs.defaultEnvManager', + }); + + // Wait for debounce + await clock.tickAsync(150); + + // Configuration changes should only update in-memory state, NOT write settings + assertNoSettingsWritten('External config change'); + + pm.dispose(); + }); + + test('changing defaultEnvManager externally should NOT rewrite all project settings', async () => { + // Start with venv as default + let currentDefaultEnvManager = 'ms-python.python:venv'; + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [ + { + path: 'project-a', + envManager: 'ms-python.python:poetry', + packageManager: 'ms-python.python:pip', + }, + ] as unknown as T; + } + if (key === 'defaultEnvManager') { + return currentDefaultEnvManager as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs + addPythonProjectSettingStub.resetHistory(); + + // Simulate user changes defaultEnvManager to conda in settings.json + currentDefaultEnvManager = 'ms-python.python:conda'; + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => section === 'python-envs.defaultEnvManager', + }); + + await clock.tickAsync(150); + + // The poetry project setting should NOT be overwritten with conda + assertNoSettingsWritten('Default manager change'); + + pm.dispose(); + }); + }); + + suite('Workspace Folder Changes - No Settings Writes', () => { + test('adding a workspace folder should NOT write project settings', async () => { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') return [] as unknown as T; + if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T; + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs + addPythonProjectSettingStub.resetHistory(); + + // Simulate adding a new workspace folder + const newFolder: WorkspaceFolder = { + uri: Uri.file(`${workspacePath}/new-folder`), + name: 'new-folder', + index: 1, + }; + (workspaceApis.getWorkspaceFolders as sinon.SinonStub).returns([workspaceFolder, newFolder]); + workspaceFoldersChangeEmitter.fire({ + added: [newFolder], + removed: [], + }); + + await clock.tickAsync(150); + + // Adding workspace folders should NOT automatically create project settings + assertNoSettingsWritten('Workspace folder added'); + + pm.dispose(); + }); + + test('removing a workspace folder should NOT write additional settings', async () => { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') return [] as unknown as T; + if (key === 'defaultEnvManager') return 'ms-python.python:venv' as T; + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + // Reset stubs - we specifically check addPythonProjectSetting and setAllManagerSettings + addPythonProjectSettingStub.resetHistory(); + setAllManagerSettingsStub.resetHistory(); + + // Simulate removing a workspace folder + workspaceFoldersChangeEmitter.fire({ + added: [], + removed: [workspaceFolder], + }); + + await clock.tickAsync(150); + + // Removing workspace folders should NOT write new/additional settings + assert.ok(!addPythonProjectSettingStub.called, 'Should not add settings when folder removed'); + assert.ok(!setAllManagerSettingsStub.called, 'Should not update manager settings when folder removed'); + + pm.dispose(); + }); + }); + + suite('Multiple Reload Cycles - No Settings Accumulation', () => { + test('multiple initializations should NOT accumulate settings writes', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + // Simulate multiple extension reload cycles + for (let i = 0; i < 3; i++) { + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + assertNoSettingsWritten(`Reload cycle ${i + 1}`); + + pm.dispose(); + } + }); + + test('reinitializing after dispose should NOT write settings', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm1 = new PythonProjectManagerImpl(); + pm1.initialize(); + await clock.tickAsync(150); + pm1.dispose(); + + // Reset stubs between lifecycle + addPythonProjectSettingStub.resetHistory(); + + const pm2 = new PythonProjectManagerImpl(); + pm2.initialize(); + await clock.tickAsync(150); + + assertNoSettingsWritten('Second initialization'); + + pm2.dispose(); + }); + }); + + suite('Getter Operations - Side-Effect Free', () => { + test('getProjects() should be side-effect free', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + addPythonProjectSettingStub.resetHistory(); + + // Call getProjects multiple times + for (let i = 0; i < 5; i++) { + pm.getProjects(); + } + + assertNoSettingsWritten('getProjects() calls'); + + pm.dispose(); + }); + + test('get() should be side-effect free', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + addPythonProjectSettingStub.resetHistory(); + + // Call get() with various URIs + pm.get(Uri.file(`${workspacePath}/alice`)); + pm.get(Uri.file(`${workspacePath}/nonexistent`)); + pm.get(Uri.file(`${workspacePath}/alice/bob/file.py`)); + + assertNoSettingsWritten('get() calls'); + + pm.dispose(); + }); + + test('create() should be side-effect free (does not add to settings)', async () => { + const mockConfig = createMockConfigWithExplicitProjectSettings(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + addPythonProjectSettingStub.resetHistory(); + + // create() just creates the object, doesn't persist it + pm.create('test-project', Uri.file(`${workspacePath}/test`)); + + assertNoSettingsWritten('create() call'); + + pm.dispose(); + }); + }); + + suite('add() - Should Write Settings (for user-initiated additions)', () => { + // Note: Testing add() behavior directly requires more complex mocking because + // add() uses workspace.getConfiguration directly. The key behavioral distinction + // is tested via the file event tests (projectManager.fileEvents.unit.test.ts) + // and the fact that initialize() does NOT call addPythonProjectSetting proves + // the separation of concerns. + + test('add() adds projects to internal map', async () => { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [] as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + + const projectsBefore = pm.getProjects().length; + + // Directly add to internal map to verify the mechanism works + // (Full add() testing requires mocking vscode.workspace which is complex) + const newProjectUri = Uri.file(`${workspacePath}/new-project`); + const newProject = pm.create('new-project', newProjectUri); + (pm as any)._projects.set(newProjectUri.toString(), newProject); + + const projectsAfter = pm.getProjects().length; + assert.strictEqual(projectsAfter, projectsBefore + 1, 'Project should be added to internal map'); + + pm.dispose(); + }); + }); + + suite('Distinction between load and add', () => { + test('initialize() loads existing projects without writing settings', async () => { + const pythonProjects: PythonProjectSettings[] = [ + { + path: 'existing-project', + envManager: 'ms-python.python:poetry', + packageManager: 'ms-python.python:pip', + }, + ]; + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return pythonProjects as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + + // initialize() - should NOT write settings + pm.initialize(); + await clock.tickAsync(150); + + assert.ok(!addPythonProjectSettingStub.called, 'initialize() should not write settings'); + + // Verify existing project is loaded + const projects = pm.getProjects(); + const existingProject = projects.find((p) => p.uri.fsPath.includes('existing-project')); + assert.ok(existingProject, 'Existing project should be loaded from settings'); + + pm.dispose(); + }); + }); +}); + +/** + * Tests that project-specific settings are preserved during reload + * when default manager settings differ from project settings. + */ +suite('Project-Specific Settings Preservation on Reload', () => { + let disposables: Disposable[]; + let clock: sinon.SinonFakeTimers; + let workspaceFoldersChangeEmitter: EventEmitter; + let configChangeEmitter: EventEmitter; + let deleteFilesEmitter: EventEmitter<{ files: readonly Uri[] }>; + let renameFilesEmitter: EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>; + + const workspacePath = getTestWorkspacePath(); + const workspaceUri = Uri.file(workspacePath); + const workspaceFolder: WorkspaceFolder = { + uri: workspaceUri, + name: 'tests-plus-projects', + index: 0, + }; + + setup(() => { + disposables = []; + clock = sinon.useFakeTimers(); + + workspaceFoldersChangeEmitter = new EventEmitter(); + configChangeEmitter = new EventEmitter(); + deleteFilesEmitter = new EventEmitter<{ files: readonly Uri[] }>(); + renameFilesEmitter = new EventEmitter<{ files: readonly { oldUri: Uri; newUri: Uri }[] }>(); + disposables.push(workspaceFoldersChangeEmitter, configChangeEmitter, deleteFilesEmitter, renameFilesEmitter); + + sinon.stub(workspaceApis, 'onDidChangeWorkspaceFolders').callsFake((listener: any) => { + return workspaceFoldersChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidChangeConfiguration').callsFake((listener: any) => { + return configChangeEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidDeleteFiles').callsFake((listener: any) => { + return deleteFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'onDidRenameFiles').callsFake((listener: any) => { + return renameFilesEmitter.event(listener); + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves(); + sinon.stub(settingHelpers, 'updatePythonProjectSettingPath').resolves(); + }); + + teardown(() => { + clock.restore(); + sinon.restore(); + disposables.forEach((d) => d.dispose()); + }); + + test('venv projects should be preserved when defaultEnvManager is conda', async () => { + // Scenario: Multiple projects have explicit venv/pip settings, + // but defaultEnvManager is set to conda. + // On reload, all project-specific settings must be preserved. + // + // Settings: + // { + // "python-envs.pythonProjects": [ + // { "path": "alice/bob", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" }, + // { "path": "ada", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" }, + // { "path": "alice", "envManager": "ms-python.python:venv", "packageManager": "ms-python.python:pip" } + // ], + // "python-envs.defaultEnvManager": "ms-python.python:conda", + // "python-envs.defaultPackageManager": "ms-python.python:conda" + // } + + sinon.stub(settingHelpers, 'addPythonProjectSetting').resolves(); + + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [ + { path: 'alice/bob', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'ada', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + { path: 'alice', envManager: 'ms-python.python:venv', packageManager: 'ms-python.python:pip' }, + ] as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:conda' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:conda' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + // Simulate reload: create new project manager and initialize + const pm = new PythonProjectManagerImpl(); + + // Spy on add() - initialize() should NOT call add() as that would write to settings + const addSpy = sinon.spy(pm, 'add'); + + pm.initialize(); + await clock.tickAsync(150); + + // initialize() should use loadProjects() (read-only), not add() (writes settings) + assert.ok( + !addSpy.called, + `initialize() called add() which would overwrite venv/pip settings with conda defaults. ` + + `add() was called ${addSpy.callCount} time(s).`, + ); + + pm.dispose(); + }); +});