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..3d8810cd 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); @@ -177,7 +179,13 @@ export async function activate(context: ExtensionContext): Promise outputChannel.show()), commands.registerCommand('python-envs.refreshAllManagers', async () => { - await Promise.all(envManagers.managers.map((m) => m.refresh(undefined))); + await window.withProgress({ location: { viewId: 'env-managers' } }, async () => { + 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 +456,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');