diff --git a/README.md b/README.md index 4c4679c..866d7e9 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,29 @@ -# VS Code Hatch -An extension to manage Hatch environments through [`vscode-python-environments`][]. +# Hatch Code +An extension to manage [Hatch environments] through [`vscode-python-environments`][]. +To make use of it, make sure your user settings contain `"python.useEnvironmentsExtension": true`. + +[hatch environments]: https://hatch.pypa.io/latest/tutorials/environment/basic-usage/ [`vscode-python-environments`]: https://github.com/microsoft/vscode-python-environments/#readme ## Features -- List all configured Hatch envs and allow to activate them +- List all configured [Hatch environments] +- Provide controls to set them as active environment for your project, activate them in a terminal, and remove them[^1] +- Temporarily[^2] modify an environment’s packages using the configured [`installer`] + +[^1]: “remove” in this context means deleting it on disk, it will stay listed and will be recreated when interacting with it. +[^2]: since many actions currently use `hatch run` and therefore sync the environment, e.g. removing a package that is pulled in as a dependency will not persist for long. + +[`installer`]: https://hatch.pypa.io/latest/how-to/environment/select-installer/ ## Extension Settings TODO +## Limitations +- It’s pretty unclear which environments exist on disk and which don’t +- We list internal envs that users don’t usually interact with, such as `hatch-uv` and `hatch-build` +- [Terminal activation is slow](https://github.com/microsoft/vscode-python-environments/issues/1391) + ## Release Notes ### 0.0.1 Unreleased diff --git a/src/cli/hatch.ts b/src/cli/hatch.ts new file mode 100644 index 0000000..f978be1 --- /dev/null +++ b/src/cli/hatch.ts @@ -0,0 +1,81 @@ +import { run } from './index.js' + +export interface HatchEnvInfo { + name: string + path: string + conf: HatchEnvConf + projectPath: string +} + +export interface HatchEnvConf { + installer: 'uv' | 'pip' + type: 'virtual' + dependencies?: string[] + 'extra-dependencies'?: string[] + scripts?: { [name: string]: string[] } + 'env-vars'?: { [name: string]: string } + 'default-args'?: string[] + features?: string[] + python?: string + 'skip-install'?: boolean + 'pre-install-commands'?: string[] + 'post-install-commands'?: string[] + platforms?: ('windows' | 'linux' | 'macos')[] + description?: string +} + +export async function getEnvs(projectPath: string): Promise { + const json = await run('hatch', ['env', 'show', '--json'], { + cwd: projectPath, + }) + const envs = JSON.parse(json) as { [name: string]: HatchEnvConf } + return await Promise.all( + Object.entries(envs).map(async ([name, conf]) => ({ + name, + conf, + path: await findEnv(name, projectPath), + projectPath, + })), + ) +} + +export async function findEnv( + name: string, + projectPath: string, +): Promise { + const results = await run('hatch', ['env', 'find', name], { + cwd: projectPath, + }) + const [p] = results + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + return p +} + +interface CreateEnvOptions { + mode?: 'create' | 'sync' | 'ensure' +} + +export async function createEnv( + name: string, + projectPath: string, + { mode = 'create' }: CreateEnvOptions = {}, +): Promise { + const args = + mode === 'sync' + ? ['-e', name, 'run', 'python', '-V'] + : ['env', 'create', name] + if (mode === 'ensure') + try { + await run('hatch', args, { cwd: projectPath }) + } catch (_) {} + await run('hatch', args, { cwd: projectPath }) +} + +export async function removeEnv( + name: string, + projectPath: string, +): Promise { + await run('hatch', ['env', 'remove', name], { cwd: projectPath }) +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..edad9d7 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,26 @@ +import { + type ExecFileException, + execFile as execFileCb, + type ProcessEnvOptions, +} from 'node:child_process' +import paths from 'node:path' +import { promisify } from 'node:util' +import { traceError } from '../common/logging.js' +import { isWindows } from '../common/platform.js' + +const execFile = promisify(execFileCb) + +export async function run( + cmd: string, + args: string[], + opts: ProcessEnvOptions = {}, +): Promise { + try { + const { stdout } = await execFile(cmd, args, opts) + return stdout + } catch (e) { + const err = e as ExecFileException + traceError(err, err.stderr) + throw err + } +} diff --git a/src/cli/installer.ts b/src/cli/installer.ts new file mode 100644 index 0000000..516a801 --- /dev/null +++ b/src/cli/installer.ts @@ -0,0 +1,36 @@ +import type { HatchEnvInfo } from './hatch.js' +import { run } from './index.js' + +async function runPipOrUv(env: HatchEnvInfo, args: string[]): Promise { + const args_ = + env.conf.installer === 'uv' + ? ['uv', 'pip', ...args] + : ['pip', ...args, ...(args[0] === 'uninstall' ? ['--yes'] : [])] + + return run('hatch', ['-e', env.name, 'run', ...args_], { + cwd: env.projectPath, + }) +} + +export async function listPackages( + env: HatchEnvInfo, +): Promise<{ name: string; version: string }[]> { + const json = await runPipOrUv(env, ['list', '--format=json']) + return JSON.parse(json) as { name: string; version: string }[] +} + +export async function installPackages( + env: HatchEnvInfo, + packages: string[], + { upgrade = false }: { upgrade?: boolean } = {}, +): Promise { + const args = [...(upgrade ? ['--upgrade'] : []), ...packages] + await runPipOrUv(env, ['install', ...args]) +} + +export async function uninstallPackages( + env: HatchEnvInfo, + packages: string[], +): Promise { + await runPipOrUv(env, ['uninstall', ...packages]) +} diff --git a/src/extension.ts b/src/extension.ts index a86bfb1..c7d7fa0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,6 +2,7 @@ import { type ExtensionContext, window } from 'vscode' import { registerLogger } from './common/logging.js' import { setWorkspacePersistentState } from './common/persistent-state.js' import { HatchEnvManager } from './hatch-env-manager.js' +import { HatchPackageManager } from './hatch-pkg-manager.js' import { getEnvExtApi } from './python-envs-api.js' export async function activate(context: ExtensionContext) { @@ -12,7 +13,11 @@ export async function activate(context: ExtensionContext) { const api = await getEnvExtApi() await setWorkspacePersistentState(context) // resolves instantly const envManager = new HatchEnvManager(api, log) - context.subscriptions.push(api.registerEnvironmentManager(envManager)) + const pkgManager = new HatchPackageManager(api, log) + context.subscriptions.push( + api.registerEnvironmentManager(envManager), + api.registerPackageManager(pkgManager), + ) } export function deactivate() {} diff --git a/src/hatch-cli.ts b/src/hatch-cli.ts deleted file mode 100644 index baa389d..0000000 --- a/src/hatch-cli.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { - type ExecFileException, - execFile as execFileCb, - type ProcessEnvOptions, -} from 'node:child_process' -import { promisify } from 'node:util' -import type { Uri } from 'vscode' -import { traceError } from './common/logging.js' - -const execFile = promisify(execFileCb) - -export interface HatchEnvInfo { - name: string - path: string - conf: HatchEnvConf -} - -export interface HatchEnvConf { - installer: 'uv' | 'pip' - type: 'virtual' - dependencies?: string[] - 'extra-dependencies'?: string[] - scripts?: { [name: string]: string[] } - 'env-vars'?: { [name: string]: string } - 'default-args'?: string[] - features?: string[] - python?: string - 'skip-install'?: boolean - 'pre-install-commands'?: string[] - 'post-install-commands'?: string[] - platforms?: ('windows' | 'linux' | 'macos')[] - description?: string -} - -export async function getEnvs(cwd: string): Promise { - const json = await run('hatch', ['env', 'show', '--json'], { cwd }) - const envs = JSON.parse(json) as { [name: string]: HatchEnvConf } - return await Promise.all( - Object.entries(envs).map(async ([name, conf]) => ({ - name, - conf, - path: await findEnv(name, cwd), - })), - ) -} - -export async function findEnv(name: string, cwd: string): Promise { - const results = await run('hatch', ['env', 'find', name], { cwd }) - const [p] = results - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - return p -} - -export async function createEnv( - name: string, - fspath: string, - { existOk = false }: { existOk?: boolean } = {}, -): Promise { - const args = existOk - ? ['-e', name, 'run', 'python', '-V'] - : ['env', 'create', name] - await run('hatch', args, { cwd: fspath }) -} - -export async function removeEnv(name: string, scope: Uri): Promise { - await run('hatch', ['env', 'remove', name], { cwd: scope.fsPath }) -} - -async function run( - cmd: string, - args: string[], - opts: ProcessEnvOptions, -): Promise { - try { - const { stdout } = await execFile(cmd, args, opts) - return stdout - } catch (e) { - const err = e as ExecFileException - traceError(err, err.stderr) - throw err - } -} diff --git a/src/hatch-env-manager.ts b/src/hatch-env-manager.ts index 818b580..4e5fddd 100644 --- a/src/hatch-env-manager.ts +++ b/src/hatch-env-manager.ts @@ -1,19 +1,17 @@ import paths from 'node:path' import { EventEmitter, - type IconPath, type LogOutputChannel, - type MarkdownString, ProgressLocation, ThemeIcon, Uri, window, } from 'vscode' -import { HATCH_ID, HATCH_NAME } from './common/constants.js' +import * as hatch from './cli/hatch.js' +import { HATCH_ID, HATCH_MANAGER_ID, HATCH_NAME } from './common/constants.js' import { createDeferred, type Deferred } from './common/deferred.js' import { traceVerbose } from './common/logging.js' import { isWindows } from './common/platform.js' -import * as hatch from './hatch-cli.js' import { clearExtensionCache, getGlobalEnvId, @@ -47,19 +45,12 @@ export function isHatchEnv( return env !== undefined && 'hatch' in env } -function syncHatchEnv( - environment: HatchEnvironment, - fspath: string, -): Promise { - return hatch.createEnv(environment.hatch.name, fspath, { - existOk: true, - }) -} - export class HatchEnvManager implements EnvironmentManager { - #globalEnv: PythonEnvironment | undefined - #activeEnv = new Map() // Selected environment for each project - #projectToEnvs = new Map() // Maps a project path to its `hatch env show` output + readonly name = HATCH_ID + readonly displayName = HATCH_NAME + readonly preferredPackageManagerId = HATCH_MANAGER_ID + readonly tooltip = 'Hatch Environment Manager' + readonly iconPath = new ThemeIcon('hatch-logo') readonly #onDidChangeEnvironment = new EventEmitter() @@ -74,21 +65,17 @@ export class HatchEnvManager implements EnvironmentManager { public readonly log: LogOutputChannel, ) { this.#api = api - this.name = HATCH_ID - this.displayName = HATCH_NAME - this.preferredPackageManagerId = 'ms-python.python:pip' // HATCH_MANAGER_ID - this.tooltip = 'Hatch Environment Manager' - this.iconPath = new ThemeIcon('hatch-logo') + this.#globalEnv = undefined + this.#activeEnv = new Map() + this.#projectToEnvs = new Map() } readonly #api: PythonEnvironmentApi - - readonly name: string - readonly displayName: string - readonly preferredPackageManagerId: string - readonly description?: string - readonly tooltip: string | MarkdownString - readonly iconPath?: IconPath + #globalEnv: PythonEnvironment | undefined + /** Selected environment for each project */ + #activeEnv: Map + /** Maps a project path to its `hatch env show` output */ + #projectToEnvs: Map dispose() { this.#onDidChangeEnvironment.dispose() @@ -104,9 +91,7 @@ export class HatchEnvManager implements EnvironmentManager { if (this.#initialized) { return this.#initialized.promise } - this.#initialized = createDeferred() - try { await this.#refreshAll() } finally { @@ -114,9 +99,20 @@ export class HatchEnvManager implements EnvironmentManager { } } + async remove(environment: PythonEnvironment): Promise { + if (!isHatchEnv(environment)) return + await hatch.removeEnv( + environment.hatch.name, + environment.hatch.projectPath, + ) + // Show info message as there is otherwise no visual indicator + await window.showInformationMessage( + `Removed environment “${environment.name}”`, + ) + } + async refresh(scope: RefreshEnvironmentsScope): Promise { traceVerbose(`Called refresh with scope: ${scope}`) - if (scope instanceof Uri) { await this.#refreshOne(scope) } else { @@ -128,34 +124,19 @@ export class HatchEnvManager implements EnvironmentManager { scope: GetEnvironmentsScope, ): Promise { traceVerbose(`Called getEnvironments with scope: ${scope}`) - await this.initialize() - - if (scope === 'all') { - return [...this.#buildEnvLookup().values()] - } - - if (scope instanceof Uri) { - const project = this.#api.getPythonProject(scope) - return project - ? this.#projectToEnvs.get(project.uri.fsPath) || [] - : [] - } - - return [] + if (scope === 'all') return [...this.#buildEnvLookup().values()] + if (scope === 'global') return [] + const project = this.#api.getPythonProject(scope) + return (project && this.#projectToEnvs.get(project.uri.fsPath)) ?? [] } async get( scope: GetEnvironmentScope, ): Promise { traceVerbose(`Called get with scope: ${scope}`) - await this.initialize() - - if (!scope) { - return this.#globalEnv - } - + if (!scope) return this.#globalEnv const project = this.#api.getPythonProject(scope) return project ? this.#activeEnv.get(project.uri.fsPath) @@ -184,19 +165,19 @@ export class HatchEnvManager implements EnvironmentManager { const uris = scope instanceof Uri ? [scope] : scope for (const uri of uris) { const project = this.#api.getPythonProject(uri) - if (!project) { - continue - } + if (!project) continue const projectPath = project.uri.fsPath if (isHatchEnv(environment)) { - await window.withProgress( - { - location: ProgressLocation.Notification, - title: 'Syncing hatch environment', - cancellable: false, - }, - () => syncHatchEnv(environment, projectPath), + const opts = { + location: ProgressLocation.Notification, + title: 'Syncing hatch environment', + cancellable: false, + } + await window.withProgress(opts, () => + hatch.createEnv(environment.hatch.name, projectPath, { + mode: 'sync', + }), ) } const oldEnv = this.#activeEnv.get(projectPath) @@ -217,7 +198,7 @@ export class HatchEnvManager implements EnvironmentManager { ): Promise { traceVerbose(`Called resolve with context: ${context}`) const project = this.#api.getPythonProject(context) - return project ? this.#activeEnv.get(project.uri.fsPath) : undefined + return project && this.#activeEnv.get(project.uri.fsPath) } async clearCache() { @@ -239,95 +220,78 @@ export class HatchEnvManager implements EnvironmentManager { ): DidChangeEnvironmentsEventArgs { const oldIds = new Set(oldEnvs.map((e) => e.envId.id)) const newIds = new Set(newEnvs.map((e) => e.envId.id)) - return [ - ...oldEnvs - .filter((e) => !newIds.has(e.envId.id)) - .map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.remove, - })), - ...newEnvs - .filter((e) => !oldIds.has(e.envId.id)) - .map((e) => ({ - environment: e, - kind: EnvironmentChangeKind.add, - })), - ] + { envs: oldEnvs, ids: newIds, kind: EnvironmentChangeKind.remove }, + { envs: newEnvs, ids: oldIds, kind: EnvironmentChangeKind.add }, + ].flatMap(({ envs, ids, kind }) => + envs + .filter((e) => !ids.has(e.envId.id)) + .map((e) => ({ environment: e, kind })), + ) } async #refreshAll(): Promise { - await window.withProgress( - { - location: ProgressLocation.Window, - title: 'Discovering Hatch environments', - }, - async () => { - const oldProjectToEnvs = new Map(this.#projectToEnvs) - this.#projectToEnvs.clear() - - // Collect project paths from registered Python projects and search paths - const projects = this.#api.getPythonProjects() - const projectMap = new Map( - projects.map((p) => [p.uri.fsPath, p]), - ) + const opts = { + location: ProgressLocation.Window, + title: 'Discovering Hatch environments', + } + await window.withProgress(opts, async () => { + const oldProjectToEnvs = new Map(this.#projectToEnvs) + this.#projectToEnvs.clear() + + // Collect project paths from registered Python projects and search paths + const projects = this.#api.getPythonProjects() + const projectMap = new Map(projects.map((p) => [p.uri.fsPath, p])) + // Hatch has no global paths, so not adding any here + const projectPaths = new Set(projectMap.keys()) + + const changes: DidChangeEnvironmentsEventArgs = [] + + await Promise.all( + [...projectPaths].map(async (projectPath) => { + const oldEnvs = oldProjectToEnvs.get(projectPath) || [] + const newEnvs = await this.#getHatchEnvs(projectPath) + + changes.push(...this.#diffEnvironments(oldEnvs, newEnvs)) + this.#projectToEnvs.set(projectPath, newEnvs) + }), + ) - const searchPathRoots = [] as const //await resolveHatchProjectPaths(); - const projectPaths = new Set([ - ...projectMap.keys(), - ...searchPathRoots, - ]) + this.#onDidChangeEnvironments.fire(changes) - const changes: DidChangeEnvironmentsEventArgs = [] + const envLookup = this.#buildEnvLookup() - await Promise.all( - [...projectPaths].map(async (projectPath) => { - const oldEnvs = oldProjectToEnvs.get(projectPath) || [] - const newEnvs = await this.#getHatchEnvs(projectPath) + // Update global environment + const globalEnvId = await getGlobalEnvId() + const globalEnv = globalEnvId + ? envLookup.get(globalEnvId) + : undefined + this.#triggerDidChangeEnvironment( + undefined, + this.#globalEnv, + globalEnv, + ) + this.#globalEnv = globalEnv - changes.push( - ...this.#diffEnvironments(oldEnvs, newEnvs), - ) - this.#projectToEnvs.set(projectPath, newEnvs) - }), - ) + // Update active environments for each project + const oldActiveEnv = new Map(this.#activeEnv) + this.#activeEnv.clear() - this.#onDidChangeEnvironments.fire(changes) + for (const projectPath of projectPaths) { + const envId = await getProjectEnvId(projectPath) + const env = envId ? envLookup.get(envId) : undefined - const envLookup = this.#buildEnvLookup() + if (env) { + this.#activeEnv.set(projectPath, env) + } - // Update global environment - const globalEnvId = await getGlobalEnvId() - const globalEnv = globalEnvId - ? envLookup.get(globalEnvId) - : undefined this.#triggerDidChangeEnvironment( - undefined, - this.#globalEnv, - globalEnv, + projectMap.get(projectPath)?.uri, + oldActiveEnv.get(projectPath), + env, ) - this.#globalEnv = globalEnv - - // Update active environments for each project - const oldActiveEnv = new Map(this.#activeEnv) - this.#activeEnv.clear() - - for (const projectPath of projectPaths) { - const envId = await getProjectEnvId(projectPath) - const env = envId ? envLookup.get(envId) : undefined - - if (env) { - this.#activeEnv.set(projectPath, env) - } - - this.#triggerDidChangeEnvironment( - projectMap.get(projectPath)?.uri, - oldActiveEnv.get(projectPath), - env, - ) - } - }, - ) + } + }) } async #refreshOne(scope: Uri): Promise { @@ -373,20 +337,17 @@ export class HatchEnvManager implements EnvironmentManager { } } - async #getHatchEnvs(path: string): Promise { - const envs = await hatch.getEnvs(path) + async #getHatchEnvs(projectPath: string): Promise { + const envs = await hatch.getEnvs(projectPath) return envs.map((e) => this.#hatch2pythonEnv(e)) } #hatch2pythonEnv({ name, - conf, path, + conf, + projectPath, }: hatch.HatchEnvInfo): HatchEnvironment { - const executable = isWindows() - ? paths.join(path, 'Scripts', 'python.exe') - : paths.join(path, 'bin', 'python') - const shellActivation: Map = new Map() const shellDeactivation: Map = @@ -407,7 +368,7 @@ export class HatchEnvManager implements EnvironmentManager { sysPrefix: path, version: '1', // TODO execInfo: { - run: { executable }, + run: { executable: envBin(path, 'python') }, shellActivation, shellDeactivation, }, @@ -419,7 +380,13 @@ export class HatchEnvManager implements EnvironmentManager { return { ...envInfo, envId: { id: path, managerId }, - hatch: { name, conf, path }, + hatch: { name, path, conf, projectPath }, } } } + +export function envBin(envPath: string, name: string): string { + return isWindows() + ? paths.join(envPath, 'Scripts', `${name}.exe`) + : paths.join(envPath, 'bin', name) +} diff --git a/src/hatch-pkg-manager.ts b/src/hatch-pkg-manager.ts new file mode 100644 index 0000000..4d68e54 --- /dev/null +++ b/src/hatch-pkg-manager.ts @@ -0,0 +1,129 @@ +import { + EventEmitter, + type LogOutputChannel, + ProgressLocation, + ThemeIcon, + window, +} from 'vscode' +import { + installPackages, + listPackages, + uninstallPackages, +} from './cli/installer.js' +import { HATCH_ID, HATCH_NAME } from './common/constants.js' +import { isHatchEnv } from './hatch-env-manager.js' +import { + type DidChangePackagesEventArgs, + type Package, + PackageChangeKind, + type PackageManagementOptions, + type PackageManager, + type PythonEnvironment, + type PythonEnvironmentApi, +} from './vscode-python-environments/index.js' + +export class HatchPackageManager implements PackageManager { + readonly name = HATCH_ID + readonly displayName = HATCH_NAME + readonly tooltip = 'Hatch Package Manager' + readonly iconPath = new ThemeIcon('hatch-logo') + + readonly #onDidChangePackages: EventEmitter = + new EventEmitter() + readonly onDidChangePackages = this.#onDidChangePackages.event + + constructor( + api: PythonEnvironmentApi, + readonly log: LogOutputChannel, + ) { + this.#api = api + this.#packages = new Map() + } + + readonly #api: PythonEnvironmentApi + /** Map from environment path to packages */ + readonly #packages: Map + + dispose() { + this.#onDidChangePackages.dispose() + } + + async manage( + environment: PythonEnvironment, + { upgrade, install = [], uninstall = [] }: PackageManagementOptions, + ): Promise { + if (!isHatchEnv(environment)) return + if (install.length > 0) { + await installPackages(environment.hatch, install, { + upgrade, + }) + } + if (uninstall.length > 0) { + await uninstallPackages(environment.hatch, uninstall) + } + await this.refresh(environment) + } + + async refresh(environment: PythonEnvironment): Promise { + if (!isHatchEnv(environment)) return + const { path: envPath } = environment.hatch + const opts = { + location: ProgressLocation.Window, + title: 'Syncing hatch environment', + cancellable: false, + } + const packages = await window.withProgress(opts, async () => { + const packages = await listPackages(environment.hatch) + return packages.map(({ name, version }) => + this.#api.createPackageItem( + { name, displayName: name, version }, + environment, + this, + ), + ) + }) + + const oldPackages = this.#packages.get(envPath) ?? [] + this.#packages.set(envPath, packages) + + const changes = this.#diffPkgs(oldPackages, packages) + if (changes.length > 0) { + this.#onDidChangePackages.fire({ + environment, + manager: this, + changes, + }) + } + } + + async getPackages( + environment: PythonEnvironment, + ): Promise { + if (!isHatchEnv(environment)) return undefined + const packages = this.#packages.get(environment.hatch.path) + if (packages !== undefined) return packages + await this.refresh(environment) + return this.#packages.get(environment.hatch.path) + } + + async clearCache(): Promise { + this.#packages.clear() + } + + #diffPkgs( + oldPackages: Package[], + packages: Package[], + ): DidChangePackagesEventArgs['changes'] { + const oldIds = new Set(oldPackages.map((p) => p.pkgId.id)) + const newIds = new Set(packages.map((p) => p.pkgId.id)) + + return [ + { pkgs: oldPackages, ids: newIds, kind: PackageChangeKind.remove }, + { pkgs: packages, ids: oldIds, kind: PackageChangeKind.add }, + ].flatMap(({ pkgs, ids, kind }) => + pkgs + .filter((p) => !ids.has(p.pkgId.id)) + .map((pkg) => ({ pkg, kind })), + ) + } +}