Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/managing-python-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ my_package_project/

When you create a script, the extension generates a single `.py` file with PEP 723 inline script metadata, which allows you to specify dependencies directly in the file.

An inline-script environment is built from the script's `# /// script` block and stored in the extension's cache, where it is shared by every script with the same dependencies and base interpreter. Because editing one would silently change the others, these environments are not user-managed: the Python Environments views do not offer install, uninstall, or version-change actions for them. Their package list remains visible.

Setup records which distributions it installed. If that record and the environment's contents later disagree — for example after installing a package into it from a terminal — every script sharing the environment needs setup again. Saving or reopening a script does not repair it; use the script's setup action to rebuild from its declared dependencies.

Once a mismatch is confirmed during an environment lookup, the affected scripts' setup actions return without requiring a save.

An environment whose recorded inventory is unknown, or cannot be read, is left alone rather than treated as modified.

**Delete Environment** on an inline-script environment deletes that single cached environment without a confirmation dialog and clears its known associations in the current workspace. All scripts sharing it will need setup again. Python files, project entries and settings, the base Python installation, and other cached environments are kept. Other windows discover the missing environment when they revalidate it.

Stop runs or debug sessions using the environment before deleting it. The extension refuses deletion while script environments are being created; files held open by other processes may also prevent deletion. Failures are reported rather than treated as successful removal. If deletion begins but cannot finish, affected scripts need setup again; remaining files can be removed by retrying Delete.

On Windows, changing only the letter casing of a script's filename keeps its existing environment association. A rename does not validate unsaved dependency edits or install packages.

## Assigning Environments to Projects

Each project can have its own Python environment. This is the core benefit of project management.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@
{
"command": "python-envs.packages",
"group": "inline",
"when": "view == env-managers && viewItem =~ /.*pythonEnvironment.*/"
"when": "view == env-managers && viewItem =~ /;managePackages;/"
},
{
"command": "python-envs.copyEnvPath",
Expand Down
104 changes: 104 additions & 0 deletions src/common/inlineScript/cacheLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const META_JSON_FILENAME = '.meta.json';
*/
export const META_SCHEMA_VERSION = 1 as const;
export const SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH = 64;
export const INSTALLED_PACKAGES_HASH_HEX_LENGTH = 64;
export const MAX_SOURCE_METADATA_IDENTITY_HASHES = 128;

const MAX_META_JSON_BYTES = 1024 * 1024;
Expand All @@ -44,6 +45,11 @@ export interface InlineScriptEnvMeta {
readonly lastUsedAt: string;
/** Set when packages changed outside setup; the entry no longer matches its declared dependencies. */
readonly manuallyModified?: boolean;
/**
* SHA-256 of the distributions this extension installed into the entry, as recorded under the
* cache-entry lock. Absent when never recorded — which means "unknown", never "changed".
*/
readonly installedPackagesHash?: string;
/** Bounded SHA-256 hashes of metadata identities proven for this cache entry. */
readonly sourceMetadataIdentityHashes?: readonly string[];
}
Expand Down Expand Up @@ -325,6 +331,90 @@ export function hashSourceMetadataIdentity(identity: string): string {
return crypto.createHash('sha256').update(identity, 'utf8').digest('hex');
}

/**
* Outcome of comparing an entry's recorded distributions against what is on disk.
*
* `unknown` is deliberately distinct from `changed`. Treating "no record" as "everything was
* added" is exactly the mistake that made merely listing packages invalidate a working
* environment; callers must never invalidate on `unknown`.
*/
export type InstalledPackagesComparison = 'unknown' | 'unchanged' | 'changed';

/**
* The only place this decision is made. Keep it that way.
*/
export function compareInstalledPackages(
recordedHash: string | undefined,
actualHash: string | undefined,
): InstalledPackagesComparison {
if (recordedHash === undefined || actualHash === undefined) {
return 'unknown';
}
return recordedHash === actualHash ? 'unchanged' : 'changed';
}

export function hashInstalledDistributions(distributions: readonly string[]): string {
const normalized = Array.from(new Set(distributions.map((name) => name.toLowerCase()))).sort();
return crypto.createHash('sha256').update(normalized.join('\n'), 'utf8').digest('hex');
}

/**
* `site-packages` locations inside a cached environment. Windows keeps a single `Lib/site-packages`;
* POSIX nests it under a version directory (`lib/python3.12/site-packages`), so that level is
* enumerated rather than guessed.
*/
async function getSitePackagesDirs(envDir: Uri): Promise<string[]> {
if (isWindows()) {
return [path.join(envDir.fsPath, 'Lib', 'site-packages')];
}
const libPath = path.join(envDir.fsPath, 'lib');
let entries: string[];
try {
entries = await fsapi.readdir(libPath);
} catch {
return [];
}
return entries.filter((entry) => entry.startsWith('python')).map((entry) => path.join(libPath, entry, 'site-packages'));
}

/**
* Names of the installed distributions in a cached environment, derived from `*.dist-info`
* directory names (which carry both name and version). Returns `undefined` when the inventory
* cannot be read, so callers see `unknown` rather than a false `changed`.
*
* Distributions that only ship legacy `.egg-info` are not observed here, matching the scope the
* package watcher previously treated as authoritative.
*/
export async function readInstalledDistributions(envDir: Uri): Promise<string[] | undefined> {
const sitePackagesDirs = await getSitePackagesDirs(envDir);
if (sitePackagesDirs.length === 0) {
return undefined;
}
const distributions: string[] = [];
let readAny = false;
for (const sitePackages of sitePackagesDirs) {
let entries: string[];
try {
entries = await fsapi.readdir(sitePackages);
} catch (error) {
if (isFileNotFoundError(error)) {
continue;
}
traceWarn(`inline-script env: failed to list ${sitePackages}:`, error);
return undefined;
}
readAny = true;
distributions.push(...entries.filter((entry) => entry.endsWith('.dist-info')));
}
return readAny ? distributions : undefined;
}

/** Current inventory hash for an entry, or `undefined` when it cannot be determined. */
export async function readInstalledPackagesHash(envDir: Uri): Promise<string | undefined> {
const distributions = await readInstalledDistributions(envDir);
return distributions === undefined ? undefined : hashInstalledDistributions(distributions);
}

export function mergeSourceMetadataIdentityHashes(
existing: readonly string[] | undefined,
current: string | undefined,
Expand Down Expand Up @@ -470,6 +560,9 @@ function validateMeta(value: unknown): InlineScriptEnvMeta | 'unsupported' | und
if (obj.manuallyModified !== undefined && typeof obj.manuallyModified !== 'boolean') {
return undefined;
}
if (obj.installedPackagesHash !== undefined && !isInstalledPackagesHash(obj.installedPackagesHash)) {
return undefined;
}
const sourceMetadataIdentityHashes = validateSourceMetadataIdentityHashes(obj.sourceMetadataIdentityHashes);
if (obj.sourceMetadataIdentityHashes !== undefined && sourceMetadataIdentityHashes === undefined) {
return undefined;
Expand All @@ -481,10 +574,21 @@ function validateMeta(value: unknown): InlineScriptEnvMeta | 'unsupported' | und
baseInterpreterVersion: obj.baseInterpreterVersion,
lastUsedAt: obj.lastUsedAt,
...(obj.manuallyModified === true ? { manuallyModified: true } : {}),
...(isInstalledPackagesHash(obj.installedPackagesHash)
? { installedPackagesHash: obj.installedPackagesHash }
: {}),
...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}),
};
}

function isInstalledPackagesHash(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length === INSTALLED_PACKAGES_HASH_HEX_LENGTH &&
/^[0-9a-f]+$/.test(value)
);
}

function validateSourceMetadataIdentityHashes(value: unknown): readonly string[] | undefined {
if (value === undefined) {
return undefined;
Expand Down
30 changes: 30 additions & 0 deletions src/common/inlineScript/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import { l10n } from 'vscode';

export class InlineScriptEnvironmentModifiedError extends Error {
constructor() {
super(l10n.t(
'This inline-script environment has modified packages. Set up the script environment again before selecting it.',
));
this.name = 'InlineScriptEnvironmentModifiedError';
}
}

/**
* Raised when a package-management command targets an inline-script environment.
*
* These environments are built from a script's `# /// script` block and are shared by every
* script with the same dependencies and base interpreter, so editing their packages by hand
* would silently change other scripts. The tree view hides the actions; this covers the command
* palette, which can still resolve one from the active script.
*/
export class InlineScriptPackagesNotManagedError extends Error {
constructor() {
super(l10n.t(
'Packages of an inline-script environment are managed by its "# /// script" block. Edit the script\'s dependencies and set up its environment again.',
));
this.name = 'InlineScriptPackagesNotManagedError';
}
}
20 changes: 14 additions & 6 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from './api';
import { ENVS_EXTENSION_ID } from './common/constants';
import { ensureCorrectVersion } from './common/extVersion';
import { InlineScriptPackagesNotManagedError } from './common/inlineScript/errors';
import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging';
import { setPersistentState } from './common/persistentState';
import { newProjectSelection } from './common/pickers/managers';
Expand Down Expand Up @@ -346,13 +347,20 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
await removeEnvironmentCommand(item, envManagers);
}),
commands.registerCommand('python-envs.packages', async (options: unknown) => {
const { environment, packageManager } = await getPackageCommandOptions(
options,
envManagers,
projectManager,
);
let resolved;
try {
resolved = await getPackageCommandOptions(options, envManagers, projectManager);
} catch (err) {
if (!(err instanceof InlineScriptPackagesNotManagedError)) {
// Preserve the existing contract: other resolution failures still surface.
throw err;
}
traceError('Rejected a package command for an inline-script environment:', err);
await window.showErrorMessage(err.message);
return;
}
try {
packageManager.manage(environment, { install: [] });
resolved.packageManager.manage(resolved.environment, { install: [] });
} catch (err) {
traceError('Error when running command python-envs.packages', err);
}
Expand Down
44 changes: 42 additions & 2 deletions src/features/envCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isPackageVersionLookupNotSupportedError,
} from '../api';
import { traceError, traceInfo, traceVerbose } from '../common/logging';
import { InlineScriptEnvironmentModifiedError, InlineScriptPackagesNotManagedError } from '../common/inlineScript/errors';
import * as persistentState from '../common/persistentState';
import {
EnvironmentManagers,
Expand Down Expand Up @@ -311,7 +312,13 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir
}
} else if (context instanceof ProjectEnvironment) {
const view = context as ProjectEnvironment;
const manager = managers.getEnvironmentManager(view.parent.project.uri);
const inlineScript = view.environment.envId.managerId === INLINE_SCRIPT_MANAGER_ID;
const manager = managers.getEnvironmentManager(
inlineScript ? view.environment : view.parent.project.uri,
);
if (inlineScript && !manager) {
throw new Error(l10n.t('The inline-script environment manager is not available to delete this environment.'));
}
await manager?.remove(view.environment);
} else {
traceError(`Invalid context for remove command: ${context}`);
Expand Down Expand Up @@ -438,6 +445,22 @@ export async function setEnvironmentCommand(
context: unknown,
em: EnvironmentManagers,
wm: PythonProjectManager,
): Promise<void> {
try {
await setEnvironmentCommandInternal(context, em, wm);
} catch (error) {
if (!(error instanceof InlineScriptEnvironmentModifiedError)) {
throw error;
}
traceError('Cannot select a modified inline-script environment:', error);
await showErrorMessage(error.message);
}
}

async function setEnvironmentCommandInternal(
context: unknown,
em: EnvironmentManagers,
wm: PythonProjectManager,
): Promise<void> {
if (context instanceof PythonEnvTreeItem) {
try {
Expand Down Expand Up @@ -733,11 +756,28 @@ export async function getPackageCommandOptions(
): Promise<{
packageManager: InternalPackageManager;
environment: PythonEnvironment;
}> {
const options = await resolvePackageCommandOptions(e, em, pm);
// The tree view hides package actions for inline-script environments, but the command palette
// can still resolve one from the active script. Refuse here so every entry point agrees.
if (options.environment.envId.managerId === INLINE_SCRIPT_MANAGER_ID) {
throw new InlineScriptPackagesNotManagedError();
}
return options;
}

async function resolvePackageCommandOptions(
e: unknown,
em: EnvironmentManagers,
pm: PythonProjectManager,
): Promise<{
packageManager: InternalPackageManager;
environment: PythonEnvironment;
}> {
if (e === undefined) {
const project = await pickProject(pm.getProjects());
if (project) {
return getPackageCommandOptions(project.uri, em, pm);
return resolvePackageCommandOptions(project.uri, em, pm);
}
}

Expand Down
Loading
Loading