diff --git a/api/package-lock.json b/api/package-lock.json index 3795b322..764e8049 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -8,7 +8,11 @@ "name": "@vscode/python-environments", "version": "1.0.0", "license": "MIT", + "dependencies": { + "@renovatebot/pep440": "^3.1.0" + }, "devDependencies": { + "@types/node": "^22.0.0", "@types/vscode": "^1.99.0", "typescript": "^5.1.3" }, @@ -17,6 +21,26 @@ "vscode": "^1.110.0" } }, + "node_modules/@renovatebot/pep440": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-3.1.0.tgz", + "integrity": "sha512-Tx/wEv92j3HmFwlqfNp8Pq/BMJPVk8c5so/Ae8eHccceBeeZx4QDuLf6RYfXJ6kvw8H05K1KPoZSsigLQA7Rqg==", + "license": "Apache-2.0", + "engines": { + "node": "^18.12.0 || ^20.0.0 || ^22.11.0", + "pnpm": "^8.6.11" + } + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/vscode": { "version": "1.109.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", @@ -37,6 +61,13 @@ "engines": { "node": ">=14.17" } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/api/package.json b/api/package.json index cd792cee..bd05ec03 100644 --- a/api/package.json +++ b/api/package.json @@ -37,5 +37,8 @@ "@types/node": "^22.0.0", "@types/vscode": "^1.99.0", "typescript": "^5.1.3" + }, + "dependencies": { + "@renovatebot/pep440": "^3.1.0" } } diff --git a/api/src/main.ts b/api/src/main.ts index 32cc9ff9..8fe5e49b 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -20,6 +20,8 @@ import { * This is the public API for other extensions to interact with the Python Environments extension. */ +export type { Pep440Version } from '@renovatebot/pep440'; +import type { Pep440Version } from '@renovatebot/pep440'; /** * The path to an icon, or a theme-specific configuration of icons. */ @@ -691,6 +693,35 @@ export interface PackageManager { * @returns A promise that resolves when the cache is cleared. */ clearCache?(): Promise; + + /** + * Returns the version of the underlying package management tool (e.g., pip, conda). + * @param environment - The Python environment context. + * @returns A promise that resolves to a SemVer object, or `undefined` if not available. + */ + getVersion?(environment: PythonEnvironment): Promise; + + /** + * Retrieves the list of available versions for a given package. + * @param packageName - The name of the package to look up. + * @param environment - The Python environment context for the lookup. + * @returns A promise that resolves to an array of version strings (newest first), + * or `undefined` if this manager does not support version listing. + */ + getAvailableVersions?(packageName: string, environment: PythonEnvironment): Promise; + + /** + * Formats a versioned install specification for this package manager. + * + * Different package managers use different syntax (e.g. pip uses `name==version`, + * conda uses `name=version`). Implement this method to return the correct format. + * When absent, callers should default to `name==version`. + * + * @param packageName - The name of the package. + * @param version - The version string. + * @returns The install specification string (e.g. `"requests==2.31.0"` or `"requests=2.31.0"`). + */ + formatInstallSpec?(packageName: string, version: string): string; } /** @@ -1059,12 +1090,33 @@ export interface PythonPackageManagementApi { managePackages(environment: PythonEnvironment, options: PackageManagementOptions): Promise; } +export interface Pep440VersionApi { + /** + * Get the version of the package manager tool associated with the given environment. + * + * @param environment The Python Environment whose package manager version is requested. + * @returns The SemVer version of the package manager tool, or `undefined` if not available. + */ + getPackageManagerVersion(environment: PythonEnvironment): Promise; + + /** + * Get the list of available versions for a package from the package manager + * associated with the given environment. + * + * @param packageName The name of the package. + * @param environment The Python Environment context for the lookup. + * @returns An array of version strings (newest first), or `undefined` if not supported. + */ + getAvailableVersions(packageName: string, environment: PythonEnvironment): Promise; +} + export interface PythonPackageManagerApi extends PythonPackageManagerRegistrationApi, PythonPackageGetterApi, PythonPackageManagementApi, - PythonPackageItemApi {} + PythonPackageItemApi, + Pep440VersionApi {} export interface PythonProjectCreationApi { /** diff --git a/package-lock.json b/package-lock.json index 963f4761..0591f7ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "1.31.0", "dependencies": { "@iarna/toml": "^2.2.5", + "@renovatebot/pep440": "^3.1.0", "@vscode/extension-telemetry": "^0.9.7", "@vscode/test-cli": "^0.0.10", "dotenv": "^16.4.5", "fs-extra": "^11.2.0", + "semver": "^7.8.0", "stack-trace": "0.0.10", "vscode-jsonrpc": "^9.0.0-next.5", "which": "^4.0.0" @@ -22,6 +24,7 @@ "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", "@types/node": "^22.15.1", + "@types/semver": "^7.7.1", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", "@types/vscode": "^1.99.0", @@ -580,6 +583,16 @@ "node": ">=14" } }, + "node_modules/@renovatebot/pep440": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-3.1.0.tgz", + "integrity": "sha512-Tx/wEv92j3HmFwlqfNp8Pq/BMJPVk8c5so/Ae8eHccceBeeZx4QDuLf6RYfXJ6kvw8H05K1KPoZSsigLQA7Rqg==", + "license": "Apache-2.0", + "engines": { + "node": "^18.12.0 || ^20.0.0 || ^22.11.0", + "pnpm": "^8.6.11" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -716,6 +729,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sinon": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", @@ -792,6 +812,7 @@ "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -1498,6 +1519,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1771,6 +1793,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2509,6 +2532,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4850,6 +4874,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4880,9 +4905,10 @@ "dev": true }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5523,6 +5549,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5649,6 +5676,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -5697,6 +5725,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -6318,6 +6347,11 @@ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", "optional": true }, + "@renovatebot/pep440": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@renovatebot/pep440/-/pep440-3.1.0.tgz", + "integrity": "sha512-Tx/wEv92j3HmFwlqfNp8Pq/BMJPVk8c5so/Ae8eHccceBeeZx4QDuLf6RYfXJ6kvw8H05K1KPoZSsigLQA7Rqg==" + }, "@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -6447,6 +6481,12 @@ "undici-types": "~6.21.0" } }, + "@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true + }, "@types/sinon": { "version": "17.0.3", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.3.tgz", @@ -6502,6 +6542,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz", "integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==", "dev": true, + "peer": true, "requires": { "@typescript-eslint/scope-manager": "8.16.0", "@typescript-eslint/types": "8.16.0", @@ -7009,7 +7050,8 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true + "dev": true, + "peer": true }, "acorn-import-phases": { "version": "1.0.4", @@ -7196,6 +7238,7 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, + "peer": true, "requires": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7706,6 +7749,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, + "peer": true, "requires": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9387,6 +9431,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9412,9 +9457,9 @@ } }, "semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==" }, "serialize-javascript": { "version": "7.0.5", @@ -9843,7 +9888,8 @@ "version": "5.3.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true + "dev": true, + "peer": true }, "uc.micro": { "version": "1.0.6", @@ -9929,6 +9975,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.0.tgz", "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, + "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -9962,6 +10009,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", diff --git a/package.json b/package.json index 2e575183..4c27f565 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,10 @@ "python-envs.workspaceSearchPaths": { "type": "array", "description": "%python-envs.workspaceSearchPaths.description%", - "default": [".venv", "*/.venv"], + "default": [ + ".venv", + "*/.venv" + ], "scope": "resource", "items": { "type": "string" @@ -279,6 +282,12 @@ "category": "Python Envs", "icon": "$(trash)" }, + { + "command": "python-envs.managePackageVersion", + "title": "%python-envs.managePackageVersion.title%", + "category": "Python Envs", + "icon": "$(gear)" + }, { "command": "python-envs.copyEnvPath", "title": "%python-envs.copyEnvPath.title%", @@ -511,6 +520,11 @@ "group": "inline", "when": "view == env-managers && viewItem =~ /.*pythonBrokenEnvironment.*/ && viewItem =~ /.*copied.*/" }, + { + "command": "python-envs.managePackageVersion", + "group": "inline", + "when": "view == env-managers && viewItem == python-package" + }, { "command": "python-envs.uninstallPackage", "group": "inline", @@ -553,6 +567,11 @@ "command": "python-envs.revealProjectInExplorer", "when": "view == python-projects && viewItem =~ /.*python-workspace.*/" }, + { + "command": "python-envs.managePackageVersion", + "group": "inline", + "when": "view == python-projects && viewItem == python-package" + }, { "command": "python-envs.uninstallPackage", "group": "inline", @@ -693,6 +712,7 @@ "@types/glob": "^8.1.0", "@types/mocha": "^10.0.1", "@types/node": "^22.15.1", + "@types/semver": "^7.7.1", "@types/sinon": "^17.0.3", "@types/stack-trace": "0.0.29", "@types/vscode": "^1.99.0", @@ -714,10 +734,12 @@ }, "dependencies": { "@iarna/toml": "^2.2.5", + "@renovatebot/pep440": "^3.1.0", "@vscode/extension-telemetry": "^0.9.7", "@vscode/test-cli": "^0.0.10", "dotenv": "^16.4.5", "fs-extra": "^11.2.0", + "semver": "^7.8.0", "stack-trace": "0.0.10", "vscode-jsonrpc": "^9.0.0-next.5", "which": "^4.0.0" diff --git a/package.nls.json b/package.nls.json index 3a4ddcec..c172d371 100644 --- a/package.nls.json +++ b/package.nls.json @@ -45,5 +45,6 @@ "python-envs.revealProjectInExplorer.title": "Reveal Project in Explorer", "python-envs.revealEnvInManagerView.title": "Reveal in Environment Managers View", "python-envs.runPetInTerminal.title": "Run Python Environment Tool (PET) in Terminal...", + "python-envs.managePackageVersion.title": "Manage Package Version", "python-envs.alwaysUseUv.description": "When set to true, uv will be used to manage all virtual environments if available. When set to false, uv will only manage virtual environments explicitly created by uv." } diff --git a/src/api.ts b/src/api.ts index b641ad3f..1b6cbf49 100644 --- a/src/api.ts +++ b/src/api.ts @@ -14,6 +14,9 @@ import type { Uri, } from 'vscode'; +export type { Pep440Version } from '@renovatebot/pep440'; +import type { Pep440Version } from '@renovatebot/pep440'; + /** * The path to an icon, or a theme-specific configuration of icons. */ @@ -685,6 +688,34 @@ export interface PackageManager { * @returns A promise that resolves when the cache is cleared. */ clearCache?(): Promise; + + /** + * Returns the version of the underlying package management tool (e.g., pip, conda). + * @returns A promise that resolves to a SemVer object, or `undefined` if not available. + */ + getVersion?(environment: PythonEnvironment): Promise; + + /** + * Retrieves the list of available versions for a given package. + * @param packageName - The name of the package to look up. + * @param environment - The Python environment context for the lookup. + * @returns A promise that resolves to an array of version strings (newest first), + * or `undefined` if this manager does not support version listing. + */ + getAvailableVersions?(packageName: string, environment: PythonEnvironment): Promise; + + /** + * Formats a versioned install specification for this package manager. + * + * Different package managers use different syntax (e.g. pip uses `name==version`, + * conda uses `name=version`). Implement this method to return the correct format. + * When absent, callers should default to `name==version`. + * + * @param packageName - The name of the package. + * @param version - The version string. + * @returns The install specification string (e.g. `"requests==2.31.0"` or `"requests=2.31.0"`). + */ + formatInstallSpec?(packageName: string, version: string): string; } /** @@ -1053,12 +1084,33 @@ export interface PythonPackageManagementApi { managePackages(environment: PythonEnvironment, options: PackageManagementOptions): Promise; } +export interface Pep440VersionApi { + /** + * Get the version of the package manager tool associated with the given environment. + * + * @param environment The Python Environment whose package manager version is requested. + * @returns The SemVer version of the package manager tool, or `undefined` if not available. + */ + getPackageManagerVersion(environment: PythonEnvironment): Promise; + + /** + * Get the list of available versions for a package from the package manager + * associated with the given environment. + * + * @param packageName The name of the package. + * @param environment The Python Environment context for the lookup. + * @returns An array of version strings (newest first), or `undefined` if not supported. + */ + getAvailableVersions(packageName: string, environment: PythonEnvironment): Promise; +} + export interface PythonPackageManagerApi extends PythonPackageManagerRegistrationApi, PythonPackageGetterApi, PythonPackageManagementApi, - PythonPackageItemApi {} + PythonPackageItemApi, + Pep440VersionApi {} export interface PythonProjectCreationApi { /** diff --git a/src/extension.ts b/src/extension.ts index ec8a24cd..0c3f6cb1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,6 +50,7 @@ import { createTerminalCommand, getPackageCommandOptions, handlePackageUninstall, + handlePackageVersionManagement, refreshPackagesCommand, removeEnvironmentCommand, removePythonProject, @@ -297,6 +298,9 @@ export async function activate(context: ExtensionContext): Promise { await handlePackageUninstall(context, envManagers); }), + commands.registerCommand('python-envs.managePackageVersion', async (context: unknown) => { + await handlePackageVersionManagement(context, envManagers); + }), commands.registerCommand('python-envs.set', async (item) => { await setEnvironmentCommand(item, envManagers, projectManager); }), diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index a10f8885..676ad419 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -46,9 +46,12 @@ import { activeTextEditor, showErrorMessage, showInformationMessage, + showInputBox, showOpenDialog, + showQuickPick, withProgress, } from '../common/window.apis'; +import { PEP440_VERSION_REGEX } from '../managers/builtin/pipUtils'; import { runAsTask } from './execution/runAsTask'; import { runInTerminal } from './terminal/runInTerminal'; import { TerminalManager } from './terminal/terminalManager'; @@ -306,7 +309,7 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { const moduleName = context.pkg.name; - const environment = context instanceof ProjectPackage ? context.parent.environment : context.parent.environment; + const environment = context.parent.environment; const packageManager = em.getPackageManager(environment); await packageManager?.manage(environment, { uninstall: [moduleName], install: [] }); return; @@ -314,6 +317,65 @@ export async function handlePackageUninstall(context: unknown, em: EnvironmentMa traceError(`Invalid context for uninstall command: ${typeof context}`); } +export async function handlePackageVersionManagement(context: unknown, em: EnvironmentManagers) { + if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { + const pkg = context.pkg; + const environment = context.parent.environment; + const packageManager = em.getPackageManager(environment); + + if (!packageManager) { + return; + } + + let version: string | undefined; + + // Try to fetch available versions for a QuickPick experience + const availableVersions = await withProgress( + { location: ProgressLocation.Window, title: l10n.t('Fetching available versions for {0}...', pkg.name) }, + () => packageManager.getAvailableVersions(pkg.name, environment), + ); + + if (availableVersions && availableVersions.length > 0) { + const items = availableVersions.map((v) => ({ + label: v.public, + description: v.public === pkg.version ? `$(check) ${l10n.t('Installed')}` : undefined, + })); + + const selected = await showQuickPick(items, { + title: l10n.t('Select version for {0}', pkg.name), + placeHolder: l10n.t('Choose a version or press Escape to cancel'), + }); + version = selected?.label; + } else { + // Fallback to free-text input if version listing is not available + version = await showInputBox({ + title: l10n.t('Manage Package Version'), + prompt: l10n.t('Enter the version for {0}', pkg.name), + value: pkg.version, + placeHolder: l10n.t('e.g. 1.2.3'), + validateInput: (value) => { + if (value.length === 0) { + return l10n.t('Version cannot be empty'); + } + if (!PEP440_VERSION_REGEX.test(value)) { + return l10n.t('Invalid PEP 440 version: {0}', value); + } + return undefined; + }, + }); + } + + if (version === undefined || version === pkg.version) { + return; + } + + await packageManager.manage(environment, { + install: [packageManager.formatInstallSpec(pkg.name, version)], + uninstall: [], + }); + } +} + export async function setEnvironmentCommand( context: unknown, em: EnvironmentManagers, diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index f071b0f5..66f0c0d9 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -1,356 +1,377 @@ -import { Disposable, Event, EventEmitter, TaskExecution, Terminal, Uri } from 'vscode'; -import { - CreateEnvironmentOptions, - CreateEnvironmentScope, - DidChangeEnvironmentEventArgs, - DidChangeEnvironmentsEventArgs, - DidChangeEnvironmentVariablesEventArgs, - DidChangePackagesEventArgs, - DidChangePythonProjectsEventArgs, - EnvironmentManager, - GetEnvironmentScope, - GetEnvironmentsScope, - Package, - PackageId, - PackageInfo, - PackageManagementOptions, - PackageManager, - PythonBackgroundRunOptions, - PythonEnvironment, - PythonEnvironmentApi, - PythonEnvironmentId, - PythonEnvironmentInfo, - PythonProcess, - PythonProject, - PythonProjectCreator, - PythonTaskExecutionOptions, - PythonTerminalCreateOptions, - PythonTerminalExecutionOptions, - RefreshEnvironmentsScope, - ResolveEnvironmentContext, - SetEnvironmentScope, -} from '../api'; -import { traceError, traceInfo } from '../common/logging'; -import { pickEnvironmentManager } from '../common/pickers/managers'; -import { createDeferred } from '../common/utils/deferred'; -import { checkUri } from '../common/utils/pathUtils'; -import { handlePythonPath } from '../common/utils/pythonPath'; -import { - EnvironmentManagers, - InternalEnvironmentManager, - ProjectCreators, - PythonEnvironmentImpl, - PythonPackageImpl, - PythonProjectManager, -} from '../internal.api'; -import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady'; -import { EnvVarManager } from './execution/envVariableManager'; -import { runAsTask } from './execution/runAsTask'; -import { runInBackground } from './execution/runInBackground'; -import { runInTerminal } from './terminal/runInTerminal'; -import { TerminalManager } from './terminal/terminalManager'; - -class PythonEnvironmentApiImpl implements PythonEnvironmentApi { - private readonly _onDidChangeEnvironments = new EventEmitter(); - private readonly _onDidChangeEnvironment = new EventEmitter(); - private readonly _onDidChangePythonProjects = new EventEmitter(); - private readonly _onDidChangePackages = new EventEmitter(); - private readonly _onDidChangeEnvironmentVariables = new EventEmitter(); - - constructor( - private readonly envManagers: EnvironmentManagers, - private readonly projectManager: PythonProjectManager, - private readonly projectCreators: ProjectCreators, - private readonly terminalManager: TerminalManager, - private readonly envVarManager: EnvVarManager, - private readonly disposables: Disposable[] = [], - ) { - this.disposables.push( - this._onDidChangeEnvironment, - this._onDidChangeEnvironments, - this._onDidChangePythonProjects, - this._onDidChangePackages, - this._onDidChangeEnvironmentVariables, - this.envManagers.onDidChangeActiveEnvironment((e) => { - this._onDidChangeEnvironment.fire(e); - const location = e.uri?.fsPath ?? 'global'; - traceInfo( - `Python API: Changed environment from ${e.old?.displayName} to ${e.new?.displayName} for: ${location}`, - ); - }), - this.envVarManager.onDidChangeEnvironmentVariables((e) => this._onDidChangeEnvironmentVariables.fire(e)), - ); - } - - registerEnvironmentManager(manager: EnvironmentManager, options?: { extensionId?: string }): Disposable { - const disposables: Disposable[] = []; - disposables.push(this.envManagers.registerEnvironmentManager(manager, options)); - if (manager.onDidChangeEnvironments) { - disposables.push(manager.onDidChangeEnvironments((e) => this._onDidChangeEnvironments.fire(e))); - } - if (manager.onDidChangeEnvironment) { - disposables.push( - manager.onDidChangeEnvironment((e) => { - setImmediate(() => { - // Refresh the central cache for this scope. This ensures that only the - // *selected* manager's changes propagate (refreshEnvironment checks - // getEnvironmentManager(scope) internally). It updates the cache and - // fires onDidChangeActiveEnvironment, which the Python API listens to. - this.envManagers.refreshEnvironment(e.uri).catch((err) => - traceError('Failed to refresh environment on change:', err), - ); - }); - }), - ); - } - return new Disposable(() => disposables.forEach((d) => d.dispose())); - } - - createPythonEnvironmentItem(info: PythonEnvironmentInfo, manager: EnvironmentManager): PythonEnvironment { - const mgr = this.envManagers.managers.find((m) => m.equals(manager)); - if (!mgr) { - throw new Error('Environment manager not found'); - } - const randomStr = Math.random().toString(36).substring(2); - const envId: PythonEnvironmentId = { - managerId: mgr.id, - id: `${info.name}-${randomStr}`, - }; - return new PythonEnvironmentImpl(envId, info); - } - - async createEnvironment( - scope: CreateEnvironmentScope, - options: CreateEnvironmentOptions | undefined, - ): Promise { - if (scope === 'global' || (!Array.isArray(scope) && scope instanceof Uri)) { - await waitForEnvManager(scope === 'global' ? undefined : [scope]); - const manager = this.envManagers.getEnvironmentManager(scope === 'global' ? undefined : scope); - if (!manager) { - throw new Error('No environment manager found'); - } - if (!manager.supportsCreate) { - throw new Error(`Environment manager does not support creating environments: ${manager.id}`); - } - return manager.create(scope, options); - } else if (Array.isArray(scope) && scope.length === 1 && scope[0] instanceof Uri) { - return this.createEnvironment(scope[0], options); - } else if (Array.isArray(scope) && scope.length > 0 && scope.every((s) => s instanceof Uri)) { - await waitForEnvManager(scope); - const managers: InternalEnvironmentManager[] = []; - scope.forEach((s) => { - const manager = this.envManagers.getEnvironmentManager(s); - if (manager && !managers.includes(manager) && manager.supportsCreate) { - managers.push(manager); - } - }); - - if (managers.length === 0) { - throw new Error('No environment managers found'); - } - - const managerId = await pickEnvironmentManager(managers); - if (!managerId) { - throw new Error('No environment manager selected'); - } - - const manager = managers.find((m) => m.id === managerId); - if (!manager) { - throw new Error('No environment manager found'); - } - - const result = await manager.create(scope, options); - return result; - } - } - async removeEnvironment(environment: PythonEnvironment): Promise { - await waitForEnvManagerId([environment.envId.managerId]); - const manager = this.envManagers.getEnvironmentManager(environment); - if (!manager) { - return Promise.reject(new Error('No environment manager found')); - } - return manager.remove(environment); - } - async refreshEnvironments(scope: RefreshEnvironmentsScope): Promise { - const currentScope = checkUri(scope) as RefreshEnvironmentsScope; - - if (currentScope === undefined) { - await waitForAllEnvManagers(); - await Promise.all(this.envManagers.managers.map((manager) => manager.refresh(currentScope))); - return Promise.resolve(); - } - - await waitForEnvManager([currentScope]); - const manager = this.envManagers.getEnvironmentManager(currentScope); - if (!manager) { - return Promise.reject(new Error(`No environment manager found for: ${currentScope.fsPath}`)); - } - return manager.refresh(currentScope); - } - async getEnvironments(scope: GetEnvironmentsScope): Promise { - const currentScope = checkUri(scope) as GetEnvironmentsScope; - if (currentScope === 'all' || currentScope === 'global') { - await waitForAllEnvManagers(); - const promises = this.envManagers.managers.map((manager) => manager.getEnvironments(currentScope)); - const items = await Promise.all(promises); - return items.flat(); - } - - await waitForEnvManager([currentScope]); - const manager = this.envManagers.getEnvironmentManager(currentScope); - if (!manager) { - return []; - } - - const items = await manager.getEnvironments(currentScope); - return items; - } - onDidChangeEnvironments: Event = this._onDidChangeEnvironments.event; - async setEnvironment(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { - const currentScope = checkUri(scope) as SetEnvironmentScope; - await waitForEnvManager( - currentScope ? (currentScope instanceof Uri ? [currentScope] : currentScope) : undefined, - ); - return this.envManagers.setEnvironment(currentScope, environment); - } - async getEnvironment(scope: GetEnvironmentScope): Promise { - const currentScope = checkUri(scope) as GetEnvironmentScope; - await waitForEnvManager(currentScope ? [currentScope] : undefined); - return this.envManagers.getEnvironment(currentScope); - } - onDidChangeEnvironment: Event = this._onDidChangeEnvironment.event; - async resolveEnvironment(context: ResolveEnvironmentContext): Promise { - await waitForAllEnvManagers(); - const projects = this.projectManager.getProjects(); - const projectEnvManagers: InternalEnvironmentManager[] = []; - projects.forEach((p) => { - const manager = this.envManagers.getEnvironmentManager(p.uri); - if (manager && !projectEnvManagers.includes(manager)) { - projectEnvManagers.push(manager); - } - }); - - return await handlePythonPath(context, this.envManagers.managers, projectEnvManagers); - } - - registerPackageManager(manager: PackageManager, options?: { extensionId?: string }): Disposable { - const disposables: Disposable[] = []; - disposables.push(this.envManagers.registerPackageManager(manager, options)); - if (manager.onDidChangePackages) { - disposables.push(manager.onDidChangePackages((e) => this._onDidChangePackages.fire(e))); - } - return new Disposable(() => disposables.forEach((d) => d.dispose())); - } - async managePackages(context: PythonEnvironment, options: PackageManagementOptions): Promise { - await waitForEnvManagerId([context.envId.managerId]); - const manager = this.envManagers.getPackageManager(context); - if (!manager) { - return Promise.reject(new Error('No package manager found')); - } - return manager.manage(context, options); - } - async refreshPackages(context: PythonEnvironment): Promise { - await waitForEnvManagerId([context.envId.managerId]); - const manager = this.envManagers.getPackageManager(context); - if (!manager) { - return Promise.reject(new Error('No package manager found')); - } - return manager.refresh(context); - } - async getPackages(context: PythonEnvironment): Promise { - await waitForEnvManagerId([context.envId.managerId]); - const manager = this.envManagers.getPackageManager(context); - if (!manager) { - return Promise.resolve(undefined); - } - return manager.getPackages(context); - } - onDidChangePackages: Event = this._onDidChangePackages.event; - createPackageItem(info: PackageInfo, environment: PythonEnvironment, manager: PackageManager): Package { - const mgr = this.envManagers.packageManagers.find((m) => m.equals(manager)); - if (!mgr) { - throw new Error('Package manager not found'); - } - const randomStr = Math.random().toString(36).substring(2); - const pkg: PackageId = { - managerId: mgr.id, - environmentId: environment.envId.id, - id: `${info.name}-${randomStr}`, - }; - return new PythonPackageImpl(pkg, info); - } - - addPythonProject(projects: PythonProject | PythonProject[]): void { - this.projectManager.add(projects); - } - removePythonProject(pyWorkspace: PythonProject): void { - this.projectManager.remove(pyWorkspace); - } - getPythonProjects(): readonly PythonProject[] { - return this.projectManager.getProjects(); - } - onDidChangePythonProjects: Event = this._onDidChangePythonProjects.event; - getPythonProject(uri: Uri): PythonProject | undefined { - return this.projectManager.get(checkUri(uri) as Uri); - } - registerPythonProjectCreator(creator: PythonProjectCreator): Disposable { - return this.projectCreators.registerPythonProjectCreator(creator); - } - async createTerminal(environment: PythonEnvironment, options: PythonTerminalCreateOptions): Promise { - return this.terminalManager.create(environment, options); - } - async runInTerminal(environment: PythonEnvironment, options: PythonTerminalExecutionOptions): Promise { - const terminal = await this.terminalManager.getProjectTerminal( - options.cwd instanceof Uri ? options.cwd : Uri.file(options.cwd), - environment, - ); - await runInTerminal(environment, terminal, options); - return terminal; - } - async runInDedicatedTerminal( - terminalKey: Uri | string, - environment: PythonEnvironment, - options: PythonTerminalExecutionOptions, - ): Promise { - const terminal = await this.terminalManager.getDedicatedTerminal( - terminalKey, - options.cwd instanceof Uri ? options.cwd : Uri.file(options.cwd), - environment, - ); - await runInTerminal(environment, terminal, options); - return Promise.resolve(terminal); - } - runAsTask(environment: PythonEnvironment, options: PythonTaskExecutionOptions): Promise { - return runAsTask(environment, options); - } - runInBackground(environment: PythonEnvironment, options: PythonBackgroundRunOptions): Promise { - return runInBackground(environment, options); - } - - onDidChangeEnvironmentVariables: Event = - this._onDidChangeEnvironmentVariables.event; - getEnvironmentVariables( - uri: Uri, - overrides?: ({ [key: string]: string | undefined } | Uri)[], - baseEnvVar?: { [key: string]: string | undefined }, - ): Promise<{ [key: string]: string | undefined }> { - return this.envVarManager.getEnvironmentVariables(checkUri(uri) as Uri, overrides, baseEnvVar); - } -} - -let _deferred = createDeferred(); -export function setPythonApi( - envMgr: EnvironmentManagers, - projectMgr: PythonProjectManager, - projectCreators: ProjectCreators, - terminalManager: TerminalManager, - envVarManager: EnvVarManager, -) { - _deferred.resolve( - new PythonEnvironmentApiImpl(envMgr, projectMgr, projectCreators, terminalManager, envVarManager), - ); -} - -export function getPythonApi(): Promise { - return _deferred.promise; -} + +import type { Pep440Version } from '@renovatebot/pep440'; +import { Disposable, Event, EventEmitter, TaskExecution, Terminal, Uri } from 'vscode'; +import { + CreateEnvironmentOptions, + CreateEnvironmentScope, + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + DidChangeEnvironmentVariablesEventArgs, + DidChangePackagesEventArgs, + DidChangePythonProjectsEventArgs, + EnvironmentManager, + GetEnvironmentScope, + GetEnvironmentsScope, + Package, + PackageId, + PackageInfo, + PackageManagementOptions, + PackageManager, + PythonBackgroundRunOptions, + PythonEnvironment, + PythonEnvironmentApi, + PythonEnvironmentId, + PythonEnvironmentInfo, + PythonProcess, + PythonProject, + PythonProjectCreator, + PythonTaskExecutionOptions, + PythonTerminalCreateOptions, + PythonTerminalExecutionOptions, + RefreshEnvironmentsScope, + ResolveEnvironmentContext, + SetEnvironmentScope, +} from '../api'; +import { traceError, traceInfo } from '../common/logging'; +import { pickEnvironmentManager } from '../common/pickers/managers'; +import { createDeferred } from '../common/utils/deferred'; +import { checkUri } from '../common/utils/pathUtils'; +import { handlePythonPath } from '../common/utils/pythonPath'; +import { + EnvironmentManagers, + InternalEnvironmentManager, + ProjectCreators, + PythonEnvironmentImpl, + PythonPackageImpl, + PythonProjectManager, +} from '../internal.api'; +import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady'; +import { EnvVarManager } from './execution/envVariableManager'; +import { runAsTask } from './execution/runAsTask'; +import { runInBackground } from './execution/runInBackground'; +import { runInTerminal } from './terminal/runInTerminal'; +import { TerminalManager } from './terminal/terminalManager'; + +class PythonEnvironmentApiImpl implements PythonEnvironmentApi { + private readonly _onDidChangeEnvironments = new EventEmitter(); + private readonly _onDidChangeEnvironment = new EventEmitter(); + private readonly _onDidChangePythonProjects = new EventEmitter(); + private readonly _onDidChangePackages = new EventEmitter(); + private readonly _onDidChangeEnvironmentVariables = new EventEmitter(); + + constructor( + private readonly envManagers: EnvironmentManagers, + private readonly projectManager: PythonProjectManager, + private readonly projectCreators: ProjectCreators, + private readonly terminalManager: TerminalManager, + private readonly envVarManager: EnvVarManager, + private readonly disposables: Disposable[] = [], + ) { + this.disposables.push( + this._onDidChangeEnvironment, + this._onDidChangeEnvironments, + this._onDidChangePythonProjects, + this._onDidChangePackages, + this._onDidChangeEnvironmentVariables, + this.envManagers.onDidChangeActiveEnvironment((e) => { + this._onDidChangeEnvironment.fire(e); + const location = e.uri?.fsPath ?? 'global'; + traceInfo( + `Python API: Changed environment from ${e.old?.displayName} to ${e.new?.displayName} for: ${location}`, + ); + }), + this.envVarManager.onDidChangeEnvironmentVariables((e) => this._onDidChangeEnvironmentVariables.fire(e)), + ); + } + + registerEnvironmentManager(manager: EnvironmentManager, options?: { extensionId?: string }): Disposable { + const disposables: Disposable[] = []; + disposables.push(this.envManagers.registerEnvironmentManager(manager, options)); + if (manager.onDidChangeEnvironments) { + disposables.push(manager.onDidChangeEnvironments((e) => this._onDidChangeEnvironments.fire(e))); + } + if (manager.onDidChangeEnvironment) { + disposables.push( + manager.onDidChangeEnvironment((e) => { + setImmediate(() => { + // Refresh the central cache for this scope. This ensures that only the + // *selected* manager's changes propagate (refreshEnvironment checks + // getEnvironmentManager(scope) internally). It updates the cache and + // fires onDidChangeActiveEnvironment, which the Python API listens to. + this.envManagers.refreshEnvironment(e.uri).catch((err) => + traceError('Failed to refresh environment on change:', err), + ); + }); + }), + ); + } + return new Disposable(() => disposables.forEach((d) => d.dispose())); + } + + createPythonEnvironmentItem(info: PythonEnvironmentInfo, manager: EnvironmentManager): PythonEnvironment { + const mgr = this.envManagers.managers.find((m) => m.equals(manager)); + if (!mgr) { + throw new Error('Environment manager not found'); + } + const randomStr = Math.random().toString(36).substring(2); + const envId: PythonEnvironmentId = { + managerId: mgr.id, + id: `${info.name}-${randomStr}`, + }; + return new PythonEnvironmentImpl(envId, info); + } + + async createEnvironment( + scope: CreateEnvironmentScope, + options: CreateEnvironmentOptions | undefined, + ): Promise { + if (scope === 'global' || (!Array.isArray(scope) && scope instanceof Uri)) { + await waitForEnvManager(scope === 'global' ? undefined : [scope]); + const manager = this.envManagers.getEnvironmentManager(scope === 'global' ? undefined : scope); + if (!manager) { + throw new Error('No environment manager found'); + } + if (!manager.supportsCreate) { + throw new Error(`Environment manager does not support creating environments: ${manager.id}`); + } + return manager.create(scope, options); + } else if (Array.isArray(scope) && scope.length === 1 && scope[0] instanceof Uri) { + return this.createEnvironment(scope[0], options); + } else if (Array.isArray(scope) && scope.length > 0 && scope.every((s) => s instanceof Uri)) { + await waitForEnvManager(scope); + const managers: InternalEnvironmentManager[] = []; + scope.forEach((s) => { + const manager = this.envManagers.getEnvironmentManager(s); + if (manager && !managers.includes(manager) && manager.supportsCreate) { + managers.push(manager); + } + }); + + if (managers.length === 0) { + throw new Error('No environment managers found'); + } + + const managerId = await pickEnvironmentManager(managers); + if (!managerId) { + throw new Error('No environment manager selected'); + } + + const manager = managers.find((m) => m.id === managerId); + if (!manager) { + throw new Error('No environment manager found'); + } + + const result = await manager.create(scope, options); + return result; + } + } + async removeEnvironment(environment: PythonEnvironment): Promise { + await waitForEnvManagerId([environment.envId.managerId]); + const manager = this.envManagers.getEnvironmentManager(environment); + if (!manager) { + return Promise.reject(new Error('No environment manager found')); + } + return manager.remove(environment); + } + async refreshEnvironments(scope: RefreshEnvironmentsScope): Promise { + const currentScope = checkUri(scope) as RefreshEnvironmentsScope; + + if (currentScope === undefined) { + await waitForAllEnvManagers(); + await Promise.all(this.envManagers.managers.map((manager) => manager.refresh(currentScope))); + return Promise.resolve(); + } + + await waitForEnvManager([currentScope]); + const manager = this.envManagers.getEnvironmentManager(currentScope); + if (!manager) { + return Promise.reject(new Error(`No environment manager found for: ${currentScope.fsPath}`)); + } + return manager.refresh(currentScope); + } + async getEnvironments(scope: GetEnvironmentsScope): Promise { + const currentScope = checkUri(scope) as GetEnvironmentsScope; + if (currentScope === 'all' || currentScope === 'global') { + await waitForAllEnvManagers(); + const promises = this.envManagers.managers.map((manager) => manager.getEnvironments(currentScope)); + const items = await Promise.all(promises); + return items.flat(); + } + + await waitForEnvManager([currentScope]); + const manager = this.envManagers.getEnvironmentManager(currentScope); + if (!manager) { + return []; + } + + const items = await manager.getEnvironments(currentScope); + return items; + } + onDidChangeEnvironments: Event = this._onDidChangeEnvironments.event; + async setEnvironment(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { + const currentScope = checkUri(scope) as SetEnvironmentScope; + await waitForEnvManager( + currentScope ? (currentScope instanceof Uri ? [currentScope] : currentScope) : undefined, + ); + return this.envManagers.setEnvironment(currentScope, environment); + } + async getEnvironment(scope: GetEnvironmentScope): Promise { + const currentScope = checkUri(scope) as GetEnvironmentScope; + await waitForEnvManager(currentScope ? [currentScope] : undefined); + return this.envManagers.getEnvironment(currentScope); + } + onDidChangeEnvironment: Event = this._onDidChangeEnvironment.event; + async resolveEnvironment(context: ResolveEnvironmentContext): Promise { + await waitForAllEnvManagers(); + const projects = this.projectManager.getProjects(); + const projectEnvManagers: InternalEnvironmentManager[] = []; + projects.forEach((p) => { + const manager = this.envManagers.getEnvironmentManager(p.uri); + if (manager && !projectEnvManagers.includes(manager)) { + projectEnvManagers.push(manager); + } + }); + + return await handlePythonPath(context, this.envManagers.managers, projectEnvManagers); + } + + registerPackageManager(manager: PackageManager, options?: { extensionId?: string }): Disposable { + const disposables: Disposable[] = []; + disposables.push(this.envManagers.registerPackageManager(manager, options)); + if (manager.onDidChangePackages) { + disposables.push(manager.onDidChangePackages((e) => this._onDidChangePackages.fire(e))); + } + return new Disposable(() => disposables.forEach((d) => d.dispose())); + } + async managePackages(context: PythonEnvironment, options: PackageManagementOptions): Promise { + await waitForEnvManagerId([context.envId.managerId]); + const manager = this.envManagers.getPackageManager(context); + if (!manager) { + return Promise.reject(new Error('No package manager found')); + } + return manager.manage(context, options); + } + async refreshPackages(context: PythonEnvironment): Promise { + await waitForEnvManagerId([context.envId.managerId]); + const manager = this.envManagers.getPackageManager(context); + if (!manager) { + return Promise.reject(new Error('No package manager found')); + } + return manager.refresh(context); + } + async getPackages(context: PythonEnvironment): Promise { + await waitForEnvManagerId([context.envId.managerId]); + const manager = this.envManagers.getPackageManager(context); + if (!manager) { + return Promise.resolve(undefined); + } + return manager.getPackages(context); + } + onDidChangePackages: Event = this._onDidChangePackages.event; + + async getPackageManagerVersion(environment: PythonEnvironment): Promise { + await waitForEnvManagerId([environment.envId.managerId]); + const manager = this.envManagers.getPackageManager(environment); + if (!manager) { + return undefined; + } + return manager.getVersion(environment); + } + + async getAvailableVersions(packageName: string, environment: PythonEnvironment): Promise { + await waitForEnvManagerId([environment.envId.managerId]); + const manager = this.envManagers.getPackageManager(environment); + if (!manager) { + return undefined; + } + return manager.getAvailableVersions(packageName, environment); + } + + createPackageItem(info: PackageInfo, environment: PythonEnvironment, manager: PackageManager): Package { + const mgr = this.envManagers.packageManagers.find((m) => m.equals(manager)); + if (!mgr) { + throw new Error('Package manager not found'); + } + const randomStr = Math.random().toString(36).substring(2); + const pkg: PackageId = { + managerId: mgr.id, + environmentId: environment.envId.id, + id: `${info.name}-${randomStr}`, + }; + return new PythonPackageImpl(pkg, info); + } + + addPythonProject(projects: PythonProject | PythonProject[]): void { + this.projectManager.add(projects); + } + removePythonProject(pyWorkspace: PythonProject): void { + this.projectManager.remove(pyWorkspace); + } + getPythonProjects(): readonly PythonProject[] { + return this.projectManager.getProjects(); + } + onDidChangePythonProjects: Event = this._onDidChangePythonProjects.event; + getPythonProject(uri: Uri): PythonProject | undefined { + return this.projectManager.get(checkUri(uri) as Uri); + } + registerPythonProjectCreator(creator: PythonProjectCreator): Disposable { + return this.projectCreators.registerPythonProjectCreator(creator); + } + async createTerminal(environment: PythonEnvironment, options: PythonTerminalCreateOptions): Promise { + return this.terminalManager.create(environment, options); + } + async runInTerminal(environment: PythonEnvironment, options: PythonTerminalExecutionOptions): Promise { + const terminal = await this.terminalManager.getProjectTerminal( + options.cwd instanceof Uri ? options.cwd : Uri.file(options.cwd), + environment, + ); + await runInTerminal(environment, terminal, options); + return terminal; + } + async runInDedicatedTerminal( + terminalKey: Uri | string, + environment: PythonEnvironment, + options: PythonTerminalExecutionOptions, + ): Promise { + const terminal = await this.terminalManager.getDedicatedTerminal( + terminalKey, + options.cwd instanceof Uri ? options.cwd : Uri.file(options.cwd), + environment, + ); + await runInTerminal(environment, terminal, options); + return Promise.resolve(terminal); + } + runAsTask(environment: PythonEnvironment, options: PythonTaskExecutionOptions): Promise { + return runAsTask(environment, options); + } + runInBackground(environment: PythonEnvironment, options: PythonBackgroundRunOptions): Promise { + return runInBackground(environment, options); + } + + onDidChangeEnvironmentVariables: Event = + this._onDidChangeEnvironmentVariables.event; + getEnvironmentVariables( + uri: Uri, + overrides?: ({ [key: string]: string | undefined } | Uri)[], + baseEnvVar?: { [key: string]: string | undefined }, + ): Promise<{ [key: string]: string | undefined }> { + return this.envVarManager.getEnvironmentVariables(checkUri(uri) as Uri, overrides, baseEnvVar); + } +} + +let _deferred = createDeferred(); +export function setPythonApi( + envMgr: EnvironmentManagers, + projectMgr: PythonProjectManager, + projectCreators: ProjectCreators, + terminalManager: TerminalManager, + envVarManager: EnvVarManager, +) { + _deferred.resolve( + new PythonEnvironmentApiImpl(envMgr, projectMgr, projectCreators, terminalManager, envVarManager), + ); +} + +export function getPythonApi(): Promise { + return _deferred.promise; +} diff --git a/src/internal.api.ts b/src/internal.api.ts index 3d896c0c..83860c9f 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -27,6 +27,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from './api'; +import type { Pep440Version } from '@renovatebot/pep440'; import { ISSUES_URL } from './common/constants'; import { CreateEnvironmentNotSupported, RemoveEnvironmentNotSupported } from './common/errors/NotSupportedError'; import { traceWarn } from './common/logging'; @@ -378,6 +379,22 @@ export class InternalPackageManager implements PackageManager { equals(other: PackageManager): boolean { return this.manager === other; } + + getVersion(environment: PythonEnvironment): Promise { + return this.manager.getVersion ? this.manager.getVersion(environment) : Promise.resolve(undefined); + } + + getAvailableVersions(packageName: string, environment: PythonEnvironment): Promise { + return this.manager.getAvailableVersions + ? this.manager.getAvailableVersions(packageName, environment) + : Promise.resolve(undefined); + } + + formatInstallSpec(packageName: string, version: string): string { + return this.manager.formatInstallSpec + ? this.manager.formatInstallSpec(packageName, version) + : `${packageName}==${version}`; + } } export interface PythonProjectManager extends Disposable { diff --git a/src/managers/builtin/helpers.ts b/src/managers/builtin/helpers.ts index 911bc603..5bbdd144 100644 --- a/src/managers/builtin/helpers.ts +++ b/src/managers/builtin/helpers.ts @@ -6,6 +6,45 @@ import { createDeferred } from '../../common/utils/deferred'; import { getConfiguration } from '../../common/workspace.apis'; import { getUvEnvironments } from './uvEnvironments'; +/** + * Result of running a process, capturing all output regardless of exit code. + */ +export interface ProcessResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +/** + * Runs a process and captures stdout/stderr regardless of exit code. + * Unlike runPython/runUV, this never rejects on non-zero exit codes. + */ +export function runProcessCaptureAll( + command: string, + args: string[], + log?: LogOutputChannel, + cwd?: string, +): Promise { + log?.info(`Running: ${command} ${args.join(' ')}`); + return new Promise((resolve) => { + const proc = spawnProcess(command, args, { cwd }); + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (data) => { + stdout += data.toString('utf-8'); + }); + proc.stderr?.on('data', (data) => { + stderr += data.toString('utf-8'); + }); + proc.on('error', () => { + resolve({ stdout, stderr, exitCode: -1 }); + }); + proc.on('close', (code) => { + resolve({ stdout, stderr, exitCode: code }); + }); + }); +} + let available = createDeferred(); /** diff --git a/src/managers/builtin/pipManager.ts b/src/managers/builtin/pipManager.ts index 81d26ea0..8afd490a 100644 --- a/src/managers/builtin/pipManager.ts +++ b/src/managers/builtin/pipManager.ts @@ -1,3 +1,5 @@ +import { compare, explain as parse, rcompare } from '@renovatebot/pep440'; +import type { Pep440Version } from '@renovatebot/pep440'; import { CancellationError, Disposable, @@ -19,6 +21,7 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { runProcessCaptureAll, runPython, runUV, shouldUseUv } from './helpers'; import { getWorkspacePackagesToInstall } from './pipUtils'; import { managePackages, refreshPackages } from './utils'; import { VenvManager } from './venvManager'; @@ -131,8 +134,121 @@ export class PipPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } + async getVersion(environment: PythonEnvironment): Promise { + try { + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + if (useUv) { + const result = await runUV(['--version'], undefined, this.log); + // "uv X.Y.Z" + const match = result.match(/^uv\s+(\d+\.\d+(?:\.\d+)*)/); + return match ? parse(match[1]) ?? undefined : undefined; + } + const result = await runPython( + environment.execInfo?.run?.executable ?? 'python', + ['-m', 'pip', '--version'], + undefined, + this.log, + ); + // "pip X.Y.Z from /path/to/pip (python X.Y)" + const match = result.match(/^pip\s+(\d+\.\d+(?:\.\d+)*)/); + return match ? parse(match[1]) ?? undefined : undefined; + } catch { + return undefined; + } + } + + async getAvailableVersions(packageName: string, environment: PythonEnvironment): Promise { + try { + const python = environment.execInfo?.run?.executable; + if (!python) { + return undefined; + } + + // uv - Run pip through pipx + const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); + if (useUv) { + const output = await runUV( + ['tool', 'run', 'pip', 'index', 'versions', packageName, '--json'], + undefined, + this.log, + ); + return parsePipIndexVersionsJson(output); + } + + // pip >= 21.2.0 - use `pip index versions --json` to get available versions in a machine readable format. + const pipVersion = await this.getVersion(environment); + if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--json'], + undefined, + this.log, + ); + return parsePipIndexVersionsJson(output); + } + + // pip <= 20.3.4 - use `pip install ==__invalid__` to get available versions from error message. + if (pipVersion && compare(pipVersion.public, '20.3.4') <= 0) { + const result = await runProcessCaptureAll( + python, + ['-m', 'pip', 'install', `${packageName}==__invalid__`], + this.log, + ); + return parsePipInstallVersions(result.stdout + result.stderr); + } + } catch { + return undefined; + } + } + dispose(): void { this._onDidChangePackages.dispose(); this.packages.clear(); } } + +/** + * Parses the output of `pip install ==__invalid__` to extract available versions. + * Expected output format: + * ``` + * Collecting ==__invalid__ + * Could not find a version that satisfies the requirement ==__invalid__ (from versions: 1.2.3, 1.2.2, ...) + * No matching distribution found for ==__invalid__ + * ``` + */ +export function parsePipInstallVersions(output: string): Pep440Version[] | undefined { + const match = output.match(/from versions:\s*([^\)]+)\)/); + if (match && match[1]) { + return match[1] + .split(',') + .filter((v) => !!v.trim()) + .map((v) => parse(v.trim())) + .filter((v): v is Pep440Version => v !== null) + .sort((a, b) => rcompare(a.public, b.public)); + } +} + +/** + * Parses JSON output from `pip index versions --json`. + * Expected format: { "name": "...", "versions": ["1.2.3", "1.2.2", ...] } + */ +export function parsePipIndexVersionsJson(output: string): Pep440Version[] | undefined { + // Only capture output between braces + const match = output.match(/{[\s\S]*}/); + if (!match) { + return undefined; + } + try { + const parsed = JSON.parse(match[0]); + if (parsed && Array.isArray(parsed.versions) && parsed.versions.length > 0) { + return (parsed.versions as string[]) + .filter((v) => !!v.trim()) + .map((v) => parse(v)) + .filter((v): v is Pep440Version => v !== null) + .sort((a, b) => rcompare(a.public, b.public)); + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/managers/builtin/pipUtils.ts b/src/managers/builtin/pipUtils.ts index 2e7a7f85..71621e84 100644 --- a/src/managers/builtin/pipUtils.ts +++ b/src/managers/builtin/pipUtils.ts @@ -22,6 +22,15 @@ export interface PyprojectToml { requires?: unknown; }; } +/** + * PEP 440 version regex. Versions must follow PEP 440 format (e.g., "1.0.0", "2.1a3"). + * See https://peps.python.org/pep-0440/ + * This regex is adapted from the official python 'packaging' library: + * https://github.com/pypa/packaging/blob/main/src/packaging/version.py + */ +export const PEP440_VERSION_REGEX = + /^v?([0-9]+!)?([0-9]+(?:\.[0-9]+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?([0-9]+)?)?(?:(?:-([0-9]+))|(?:[-_.]?(post|rev|r)[-_.]?([0-9]+)?))?(?:[-_.]?(dev)[-_.]?([0-9]+)?)?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i; + export function validatePyprojectToml(toml: PyprojectToml): string | undefined { // 1. Validate required "requires" field in [build-system] section (PEP 518) const buildSystem = toml['build-system']; @@ -56,13 +65,7 @@ export function validatePyprojectToml(toml: PyprojectToml): string | undefined { if (version.length === 0) { return l10n.t('Version cannot be empty in pyproject.toml.'); } - // PEP 440 version regex. Versions must follow PEP 440 format (e.g., "1.0.0", "2.1a3"). - // See https://peps.python.org/pep-0440/ - // This regex is adapted from the official python 'packaging' library: - // https://github.com/pypa/packaging/blob/main/src/packaging/version.py - const versionRegex = - /^v?([0-9]+!)?([0-9]+(?:\.[0-9]+)*)(?:[-_.]?(a|b|c|rc|alpha|beta|pre|preview)[-_.]?([0-9]+)?)?(?:(?:-([0-9]+))|(?:[-_.]?(post|rev|r)[-_.]?([0-9]+)?))?(?:[-_.]?(dev)[-_.]?([0-9]+)?)?(?:\+([a-z0-9]+(?:[-_.][a-z0-9]+)*))?$/i; - if (!versionRegex.test(version)) { + if (!PEP440_VERSION_REGEX.test(version)) { return l10n.t('Invalid version "{0}" in pyproject.toml.', version); } } diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index c012ea91..53430ad7 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -1,3 +1,5 @@ +import { explain as parse, rcompare } from '@renovatebot/pep440'; +import type { Pep440Version } from '@renovatebot/pep440'; import { CancellationError, Disposable, @@ -20,7 +22,7 @@ import { import { showErrorMessageWithLogs } from '../../common/errors/utils'; import { CondaStrings } from '../../common/localize'; import { withProgress } from '../../common/window.apis'; -import { getCommonCondaPackagesToInstall, managePackages, refreshPackages } from './condaUtils'; +import { getCommonCondaPackagesToInstall, managePackages, refreshPackages, runCondaExecutable } from './condaUtils'; function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { const changes: { kind: PackageChangeKind; pkg: Package }[] = []; @@ -39,7 +41,10 @@ export class CondaPackageManager implements PackageManager, Disposable { private packages: Map = new Map(); - constructor(public readonly api: PythonEnvironmentApi, public readonly log: LogOutputChannel) { + constructor( + public readonly api: PythonEnvironmentApi, + public readonly log: LogOutputChannel, + ) { this.name = 'conda'; this.displayName = 'Conda'; this.description = CondaStrings.condaPackageMgr; @@ -122,6 +127,39 @@ export class CondaPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } + formatInstallSpec(packageName: string, version: string): string { + // conda match spec syntax uses a single `=` for version pinning + return `${packageName}=${version}`; + } + + async getVersion(_environment: PythonEnvironment): Promise { + try { + const output = await runCondaExecutable(['--version'], this.log); + // "conda X.Y.Z" + const match = output.match(/conda\s+(\d+\.\d+(?:\.\d+)*)/i); + return match ? parse(match[1]) ?? undefined : undefined; + } catch { + return undefined; + } + } + + async getAvailableVersions(packageName: string, _environment: PythonEnvironment): Promise { + try { + const output = await runCondaExecutable(['search', packageName, '--json'], this.log); + const parsed = JSON.parse(output); + if (parsed && typeof parsed === 'object' && Array.isArray(parsed[packageName])) { + return parsed[packageName] + .filter((entry: { version?: string }) => !!entry.version?.trim()) + .map((entry: { version?: string }) => parse(entry.version!)) + .filter((v: Pep440Version | null): v is Pep440Version => v !== null) + .sort((a: Pep440Version, b: Pep440Version) => rcompare(a.public, b.public)); + } + return undefined; + } catch { + return undefined; + } + } + dispose() { this._onDidChangePackages.dispose(); this.packages.clear(); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 21d5fb82..e43c61db 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -22,10 +22,12 @@ import { PythonEnvironment, PythonEnvironmentApi, } from '../../api'; +import { explain as parse } from '@renovatebot/pep440'; +import type { Pep440Version } from '@renovatebot/pep440'; import { spawnProcess } from '../../common/childProcess.apis'; import { showErrorMessage, showInputBox, withProgress } from '../../common/window.apis'; import { PoetryManager } from './poetryManager'; -import { getPoetry } from './poetryUtils'; +import { getPoetry, getPoetryVersion } from './poetryUtils'; function getChanges(before: Package[], after: Package[]): { kind: PackageChangeKind; pkg: Package }[] { const changes: { kind: PackageChangeKind; pkg: Package }[] = []; @@ -154,6 +156,22 @@ export class PoetryPackageManager implements PackageManager, Disposable { return this.packages.get(environment.envId.id); } + async getVersion(_environment: PythonEnvironment): Promise { + const poetry = await getPoetry(); + if (!poetry) { + return undefined; + } + const versionStr = await getPoetryVersion(poetry); + return versionStr ? parse(versionStr) ?? undefined : undefined; + } + + async getAvailableVersions(_packageName: string, _environment: PythonEnvironment): Promise { + // Poetry doesn't have a native "list available versions" command. + // Poetry 2.x supports `poetry search` but it was disabled on PyPI. + // Return undefined to indicate this manager doesn't support version listing. + return undefined; + } + dispose(): void { this._onDidChangePackages.dispose(); this.packages.clear(); diff --git a/src/test/managers/builtin/pipVersions.unit.test.ts b/src/test/managers/builtin/pipVersions.unit.test.ts new file mode 100644 index 00000000..d8d042c6 --- /dev/null +++ b/src/test/managers/builtin/pipVersions.unit.test.ts @@ -0,0 +1,36 @@ +import assert from 'assert'; +import { explain } from '@renovatebot/pep440'; +import { parsePipIndexVersionsJson } from '../../../managers/builtin/pipManager'; + +suite('Pip Version Parsing', () => { + suite('parsePipIndexVersionsJson', () => { + test('parses valid JSON with versions array', () => { + const output = JSON.stringify({ name: 'requests', versions: ['2.31.0', '2.30.0', '2.29.0'] }); + const versions = parsePipIndexVersionsJson(output); + assert.deepStrictEqual(versions, ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v))); + }); + + test('parses output with a single version', () => { + const output = JSON.stringify({ name: 'my-package', versions: ['1.0.0'] }); + const versions = parsePipIndexVersionsJson(output); + assert.deepStrictEqual(versions, [explain('1.0.0')]); + }); + + test('returns undefined for empty versions array', () => { + const output = JSON.stringify({ name: 'pkg', versions: [] }); + const versions = parsePipIndexVersionsJson(output); + assert.strictEqual(versions, undefined); + }); + + test('returns undefined for invalid JSON', () => { + const versions = parsePipIndexVersionsJson('not json'); + assert.strictEqual(versions, undefined); + }); + + test('returns undefined when versions field is missing', () => { + const output = JSON.stringify({ name: 'pkg' }); + const versions = parsePipIndexVersionsJson(output); + assert.strictEqual(versions, undefined); + }); + }); +});