Skip to content

Commit b717f1a

Browse files
authored
Merge branch 'main' into copilot/fix-env-file-notification-bug
2 parents 080efaf + 771f9c3 commit b717f1a

File tree

19 files changed

+537
-197
lines changed

19 files changed

+537
-197
lines changed

docs/startup-flow.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
2+
# Startup Flow for Python Environments Extension
3+
4+
5+
user opens VS Code
6+
python environments extension begins activation
7+
8+
SYNC (`activate` in extension.ts):
9+
1. create core objects: ProjectManager, EnvironmentManagers, ManagerReady
10+
2. `setPythonApi()` — API object created, deferred resolved (API is now available to consumers)
11+
3. create views (EnvManagerView, ProjectView), status bar, terminal manager
12+
4. register all commands
13+
5. activate() returns — extension is "active" from VS Code's perspective
14+
15+
📊 TELEMETRY: EXTENSION.ACTIVATION_DURATION { duration }
16+
17+
ASYNC (setImmediate callback, still in extension.ts):
18+
1. spawn PET process (`createNativePythonFinder`)
19+
1. sets up a JSON-RPC connection to it over stdin/stdout
20+
2. register all built-in managers in parallel (Promise.all):
21+
- for each manager (system, conda, pyenv, pipenv, poetry):
22+
1. check if tool exists (e.g. `getConda(nativeFinder)` asks PET for the conda binary)
23+
2. if tool not found → log, return early (manager not registered)
24+
3. if tool found → create manager, call `api.registerEnvironmentManager(manager)`
25+
- this adds it to the `EnvironmentManagers` map
26+
- fires `onDidChangeEnvironmentManager``ManagerReady` deferred resolves for this manager
27+
3. all registrations complete (Promise.all resolves)
28+
29+
--- gate point: `applyInitialEnvironmentSelection` ---
30+
📊 TELEMETRY: ENV_SELECTION.STARTED { duration (activation→here), registeredManagerCount, registeredManagerIds, workspaceFolderCount }
31+
32+
1. for each workspace folder + global scope (no workspace case), run `resolvePriorityChainCore` to find manager:
33+
- P1: pythonProjects[] setting → specific manager for this project
34+
- P2: user-configured defaultEnvManager setting
35+
- P3: user-configured python.defaultInterpreterPath → nativeFinder.resolve(path)
36+
- P4: auto-discovery → try venv manager (local .venv), fall back to system python
37+
- for workspace scope: ask venv manager if there's a local env (.venv/venv in the folder)
38+
- if found → use venv manager with that env
39+
- if not found → fall back to system python manager
40+
- for global scope: use system python manager directly
41+
42+
2. get the environment from the winning priority level:
43+
44+
--- fork point: `result.environment ?? await result.manager.get(folder.uri)` ---
45+
left side truthy = envPreResolved | left side undefined = managerDiscovery
46+
47+
envPreResolved — P3 won (interpreter → manager):
48+
`resolvePriorityChainCore` calls `tryResolveInterpreterPath()`:
49+
1. `nativeFinder.resolve(path)` — single PET call, resolves just this one binary
50+
2. find which manager owns the resolved env (by managerId)
51+
3. return { manager, environment } — BOTH are known
52+
→ result.environment is set → the `??` short-circuits
53+
→ no `manager.get()` called, no `initialize()`, no full discovery
54+
55+
managerDiscovery — P1, P2, or P4 won (manager → interpreter):
56+
`resolvePriorityChainCore` returns { manager, environment: undefined }
57+
→ result.environment is undefined → falls through to `await result.manager.get(scope)`
58+
`manager.get(scope)` (e.g. `CondaEnvManager.get()`):
59+
4. `initialize()` — lazy, once-only per manager (guarded by deferred)
60+
a. `nativeFinder.refresh(hardRefresh=false)`:
61+
`handleSoftRefresh()` checks in-memory cache (Map) for key 'all' (bc one big scan, shared cache, all managers benefit)
62+
- on reload: cache is empty (Map was destroyed) → cache miss
63+
- falls through to `handleHardRefresh()`
64+
`handleHardRefresh()`:
65+
- adds request to WorkerPool queue (concurrency 1, so serialized)
66+
- when its turn comes, calls `doRefresh()`:
67+
1. `configure()` — JSON-RPC to PET with search paths, conda/poetry/pipenv paths, cache dir
68+
2. `refresh` — JSON-RPC to PET, PET scans filesystem
69+
- PET may use its own on-disk cache (cacheDirectory) to speed this up
70+
- PET streams back results as 'environment' and 'manager' notifications
71+
- envs missing version/prefix get an inline resolve() call
72+
3. returns NativeInfo[] (all envs of all types)
73+
- result stored in in-memory cache under key 'all'
74+
→ subsequent managers calling nativeFinder.refresh(false) get cache hit → instant
75+
b. filter results to this manager's env type (e.g. conda filters to kind=conda)
76+
c. convert NativeEnvInfo → PythonEnvironment objects → populate collection
77+
d. `loadEnvMap()` — reads persisted env path from workspace state
78+
→ matches path against freshly discovered collection via `findEnvironmentByPath()`
79+
→ populates `fsPathToEnv` map
80+
5. look up scope in `fsPathToEnv` → return the matched env
81+
82+
📊 TELEMETRY: ENV_SELECTION.RESULT (per scope) { duration (priority chain + manager.get), scope, prioritySource, managerId, path, hasPersistedSelection }
83+
84+
3. env is cached in memory (no settings.json write)
85+
4. Python extension / status bar can now get the selected env via `api.getEnvironment(scope)`
86+
87+
📊 TELEMETRY: EXTENSION.MANAGER_REGISTRATION_DURATION { duration (activation→here), result, failureStage?, errorType? }
88+
89+
POST-INIT:
90+
1. register terminal package watcher
91+
2. register settings change listener (`registerInterpreterSettingsChangeListener`) — re-runs priority chain if settings change
92+
3. initialize terminal manager
93+
4. send telemetry (manager selection, project structure, discovery summary)

src/common/localize.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export namespace Common {
1616
export const quickCreate = l10n.t('Quick Create');
1717
export const installPython = l10n.t('Install Python');
1818
export const dontShowAgain = l10n.t("Don't Show Again");
19+
export const dontAskAgain = l10n.t("Don't ask again");
1920
}
2021

2122
export namespace WorkbenchStrings {
@@ -137,6 +138,13 @@ export namespace SysManagerStrings {
137138
export const packageRefreshError = l10n.t('Error refreshing packages');
138139
}
139140

141+
export namespace PixiStrings {
142+
export const pixiExtensionRecommendation = l10n.t(
143+
'Pixi environments were detected. Install the Pixi extension for full support including activation and environment management.',
144+
);
145+
export const install = l10n.t('Open on Marketplace');
146+
}
147+
140148
export namespace CondaStrings {
141149
export const condaManager = l10n.t('Manages Conda environments');
142150
export const condaDiscovering = l10n.t('Discovering Conda environments');
@@ -244,7 +252,6 @@ export namespace UvInstallStrings {
244252
export const uvInstallRestartRequired = l10n.t(
245253
'uv was installed but may not be available in the current terminal. Please restart VS Code or open a new terminal and try again.',
246254
);
247-
export const dontAskAgain = l10n.t("Don't ask again");
248255
export const clickToInstallPython = l10n.t('No Python found, click to install');
249256
export const selectPythonVersion = l10n.t('Select Python version to install');
250257
export const installed = l10n.t('installed');

src/common/telemetry/constants.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export enum EventNames {
5656
* Properties:
5757
* - managerName: string (e.g. 'system', 'conda', 'pyenv', 'pipenv', 'poetry', 'shellStartupVars')
5858
* - errorType: string (classified error category from classifyError)
59+
* - failureStage: string (hierarchical stage indicator, e.g. 'getPipenv:nativeFinderRefresh')
5960
*/
6061
MANAGER_REGISTRATION_FAILED = 'MANAGER_REGISTRATION.FAILED',
6162
/**
@@ -82,6 +83,24 @@ export enum EventNames {
8283
* - errorType: string (classified error category, on failure only)
8384
*/
8485
PET_INIT_DURATION = 'PET.INIT_DURATION',
86+
/**
87+
* Telemetry event fired when applyInitialEnvironmentSelection begins.
88+
* Signals that all managers are registered and env selection is starting.
89+
* Properties:
90+
* - registeredManagerCount: number (how many env managers registered)
91+
* - workspaceFolderCount: number (how many workspace folders to process)
92+
*/
93+
ENV_SELECTION_STARTED = 'ENV_SELECTION.STARTED',
94+
/**
95+
* Telemetry event fired per scope when the priority chain resolves.
96+
* Properties:
97+
* - scope: string ('workspace' or 'global')
98+
* - prioritySource: string (which priority won: 'pythonProjects', 'defaultEnvManager', 'defaultInterpreterPath', 'autoDiscovery')
99+
* - managerId: string (the winning manager's id)
100+
* - resolutionPath: string ('envPreResolved' = env already resolved, 'managerDiscovery' = needed full discovery)
101+
* - hasPersistedSelection: boolean (whether a persisted env path existed in workspace state)
102+
*/
103+
ENV_SELECTION_RESULT = 'ENV_SELECTION.RESULT',
85104
}
86105

87106
// Map all events to their properties
@@ -282,12 +301,14 @@ export interface IEventNamePropertyMapping {
282301
/* __GDPR__
283302
"manager_registration.failed": {
284303
"managerName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
285-
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }
304+
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
305+
"failureStage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }
286306
}
287307
*/
288308
[EventNames.MANAGER_REGISTRATION_FAILED]: {
289309
managerName: string;
290310
errorType: string;
311+
failureStage: string;
291312
};
292313

293314
/* __GDPR__
@@ -322,4 +343,34 @@ export interface IEventNamePropertyMapping {
322343
result: 'success' | 'error' | 'timeout';
323344
errorType?: string;
324345
};
346+
347+
/* __GDPR__
348+
"env_selection.started": {
349+
"registeredManagerCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
350+
"registeredManagerIds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
351+
"workspaceFolderCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
352+
}
353+
*/
354+
[EventNames.ENV_SELECTION_STARTED]: {
355+
registeredManagerCount: number;
356+
registeredManagerIds: string;
357+
workspaceFolderCount: number;
358+
};
359+
360+
/* __GDPR__
361+
"env_selection.result": {
362+
"scope": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
363+
"prioritySource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
364+
"managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
365+
"resolutionPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
366+
"hasPersistedSelection": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
367+
}
368+
*/
369+
[EventNames.ENV_SELECTION_RESULT]: {
370+
scope: string;
371+
prioritySource: string;
372+
managerId: string;
373+
resolutionPath: string;
374+
hasPersistedSelection: boolean;
375+
};
325376
}

src/common/utils/asyncUtils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ export async function safeRegister(name: string, task: Promise<void>): Promise<v
1616
await task;
1717
} catch (error) {
1818
traceError(`Failed to register ${name} features:`, error);
19+
const failureStage =
20+
error instanceof Error
21+
? ((error as Error & { failureStage?: string }).failureStage ?? 'unknown')
22+
: 'unknown';
1923
sendTelemetryEvent(EventNames.MANAGER_REGISTRATION_FAILED, undefined, {
2024
managerName: name,
2125
errorType: classifyError(error),
26+
failureStage,
2227
});
2328
}
2429
}

src/common/utils/pythonPath.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,19 @@ const priorityOrder = [
1616
`${PYTHON_EXTENSION_ID}:system`,
1717
];
1818
function sortManagersByPriority(managers: InternalEnvironmentManager[]): InternalEnvironmentManager[] {
19+
const systemId = priorityOrder[priorityOrder.length - 1];
1920
return managers.sort((a, b) => {
2021
const aIndex = priorityOrder.indexOf(a.id);
2122
const bIndex = priorityOrder.indexOf(b.id);
2223
if (aIndex === -1 && bIndex === -1) {
2324
return 0;
2425
}
2526
if (aIndex === -1) {
26-
return 1;
27+
// Unknown managers should come before system (last resort) but after other known managers
28+
return b.id === systemId ? -1 : 1;
2729
}
2830
if (bIndex === -1) {
29-
return -1;
31+
return a.id === systemId ? 1 : -1;
3032
}
3133
return aIndex - bIndex;
3234
});

src/common/workbenchCommands.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,7 @@ export async function installExtension(
1010
): Promise<void> {
1111
await commands.executeCommand('workbench.extensions.installExtension', extensionId, options);
1212
}
13+
14+
export async function openExtension(extensionId: string): Promise<void> {
15+
await commands.executeCommand('extension.open', extensionId);
16+
}

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -611,7 +611,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
611611
]);
612612

613613
failureStage = 'envSelection';
614-
await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api);
614+
await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api, start.elapsedTime);
615615

616616
// Register manager-agnostic terminal watcher for package-modifying commands
617617
failureStage = 'terminalWatcher';

src/features/interpreterSelection.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { commands, ConfigurationChangeEvent, Disposable, l10n, Uri } from 'vscod
66
import { PythonEnvironment, PythonEnvironmentApi } from '../api';
77
import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../common/constants';
88
import { traceError, traceInfo, traceVerbose, traceWarn } from '../common/logging';
9+
import { StopWatch } from '../common/stopWatch';
10+
import { EventNames } from '../common/telemetry/constants';
11+
import { sendTelemetryEvent } from '../common/telemetry/sender';
912
import { resolveVariables } from '../common/utils/internalVariables';
1013
import { showWarningMessage } from '../common/window.apis';
1114
import {
@@ -140,7 +143,12 @@ async function resolvePriorityChainCore(
140143
};
141144
errors.push(error);
142145
} else {
143-
const resolved = await tryResolveInterpreterPath(nativeFinder, api, expandedInterpreterPath, envManagers);
146+
const resolved = await tryResolveInterpreterPath(
147+
nativeFinder,
148+
api,
149+
expandedInterpreterPath,
150+
envManagers,
151+
);
144152
if (resolved) {
145153
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
146154
return { result: resolved, errors };
@@ -275,16 +283,25 @@ export async function applyInitialEnvironmentSelection(
275283
projectManager: PythonProjectManager,
276284
nativeFinder: NativePythonFinder,
277285
api: PythonEnvironmentApi,
286+
activationToReadyDurationMs?: number,
278287
): Promise<void> {
279288
const folders = getWorkspaceFolders() ?? [];
280289
traceInfo(
281290
`[interpreterSelection] Applying initial environment selection for ${folders.length} workspace folder(s)`,
282291
);
283292

293+
// Checkpoint 1: env selection starting — managers are registered
294+
sendTelemetryEvent(EventNames.ENV_SELECTION_STARTED, activationToReadyDurationMs, {
295+
registeredManagerCount: envManagers.managers.length,
296+
registeredManagerIds: envManagers.managers.map((m) => m.id).join(','),
297+
workspaceFolderCount: folders.length,
298+
});
299+
284300
const allErrors: SettingResolutionError[] = [];
285301

286302
for (const folder of folders) {
287303
try {
304+
const scopeStopWatch = new StopWatch();
288305
const { result, errors } = await resolvePriorityChainCore(
289306
folder.uri,
290307
envManagers,
@@ -294,9 +311,20 @@ export async function applyInitialEnvironmentSelection(
294311
);
295312
allErrors.push(...errors);
296313

314+
// Checkpoint 2: priority chain resolved — which path?
315+
const isPathA = result.environment !== undefined;
316+
297317
// Get the specific environment if not already resolved
298318
const env = result.environment ?? (await result.manager.get(folder.uri));
299319

320+
sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, scopeStopWatch.elapsedTime, {
321+
scope: 'workspace',
322+
prioritySource: result.source,
323+
managerId: result.manager.id,
324+
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
325+
hasPersistedSelection: env !== undefined,
326+
});
327+
300328
// Cache only — NO settings.json write (shouldPersistSettings = false)
301329
await envManagers.setEnvironment(folder.uri, env, false);
302330

@@ -311,12 +339,23 @@ export async function applyInitialEnvironmentSelection(
311339
// Also apply initial selection for global scope (no workspace folder)
312340
// This ensures defaultInterpreterPath is respected even without a workspace
313341
try {
342+
const globalStopWatch = new StopWatch();
314343
const { result, errors } = await resolvePriorityChainCore(undefined, envManagers, undefined, nativeFinder, api);
315344
allErrors.push(...errors);
316345

346+
const isPathA = result.environment !== undefined;
347+
317348
// Get the specific environment if not already resolved
318349
const env = result.environment ?? (await result.manager.get(undefined));
319350

351+
sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, globalStopWatch.elapsedTime, {
352+
scope: 'global',
353+
prioritySource: result.source,
354+
managerId: result.manager.id,
355+
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
356+
hasPersistedSelection: env !== undefined,
357+
});
358+
320359
// Cache only — NO settings.json write (shouldPersistSettings = false)
321360
await envManagers.setEnvironments('global', env, false);
322361

0 commit comments

Comments
 (0)