Skip to content

Commit 26c78f6

Browse files
Register PEP 723 scripts as exact projects (PEP 723 PR 10/16) (#1744)
> Part of #1602 (PEP 723 inline script env support). Design doc: #1601. ### Roadmap context This is **PR 10 of 16** in the PEP 723 inline-script roadmap. PRs 7-9 persist, discover, validate, and route per-script environments; this PR gives each configured script an exact project identity that survives restart and can be consumed by per-file integrations. | Phase 3 / integration | PR | Status | |---|---|---| | | PR 7: per-script persistence | merged (#1697) | | | PR 8: activation-time discovery | merged (#1722) | | | PR 9: automatic per-script routing | merged (#1729) | | | **PR 10: exact script project registration** | **this PR** | | | PRs 11-12: CodeLens and bulk setup UX | follow-up | | | PR 17: Pylance per-file Python path | [microsoft/pyrx#9265](microsoft/pyrx#9265) | | | PR 19: Python extension per-file lookup | [microsoft/vscode-python#26129](microsoft/vscode-python#26129) | ### Why this PR PR 9 can route a saved script to a validated inline environment, but the project manager still identifies the script through its containing workspace project. That prevents the per-file identity from surviving restart consistently and leaves downstream configuration and environment-change consumers without an exact script scope. The registration also needs an ownership boundary: clearing inline environments must remove entries created by the extension without deleting user-authored project settings. ### What this PR does - Registers an exact `pythonProjects` entry before binding an inline environment. - Stores the normal environment and package manager as the script's fallback rather than replacing them with the inline manager. - Marks extension-managed entries as either: - `created`: remove the entry during inline cleanup; - `adopted`: remove only the marker and preserve the user's entry. - Ignores the managed fallback entry while a validated inline association is routeable. - Uses that entry normally when the feature is disabled or the association becomes stale. - Rolls back a newly prepared registration if inline association binding fails. - Supports single and batch script selection. - Converts a managed entry into an ordinary user-owned entry when the user explicitly selects a non-inline manager. - Coordinates explicit managed selections with the dedicated clear-cache operation. - Resolves and updates same-named scripts correctly in multi-root workspaces. ### Registration and cleanup semantics | Condition | Behavior | |---|---| | No exact project entry exists | Create a marked entry containing the ordinary fallback managers | | An exact user entry exists | Temporarily mark it without changing its manager choices | | Inline binding fails | Roll back the marker/created entry and any newly added in-memory project | | Validated association exists | Route through the inline manager | | Association is stale or unavailable | Use the stored fallback manager | | User selects a non-inline manager | Update the owning setting and remove the managed marker | | Clear Script Environment Cache | Remove created entries; restore adopted entries | | Two roots contain the same relative script path | Match using the `workspace` discriminator | ### Performance and safety - No workspace scan is introduced. - Configuration writes occur only during explicit persisted selection, rollback, manager changes, or dedicated cache cleanup. - Serialization is limited to managed inline-script project mutations; unrelated environment routing and refresh operations are not queued. - User-owned project settings are never deleted by inline cleanup. ### User impact The feature remains behind `python-envs.inlineScripts.enabled`. Existing users and projects without a managed inline-script entry retain their current manager-selection behavior. After setup, a script has a stable exact project scope across reloads; when inline routing is unavailable, its previous project/workspace environment remains the fallback. ### Tests - `npm run compile-tests` - `npm run compile` - `npm run lint` - Focused command, environment-manager, and settings tests: **92 passing** - The full unit suite was also run. Six unchanged timing-sensitive inline-manager tests failed only under full-suite load and passed together in an isolated rerun. ### Scope and follow-up This PR does not add the setup CodeLens, bulk setup command, TTL eviction, or public feature enablement. Those remain in PRs 11, 12, and 14. Per-file language-service and debugger integration are handled by the companion cross-repository PRs above. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d4c8957 commit 26c78f6

9 files changed

Lines changed: 1004 additions & 420 deletions

File tree

‎src/extension.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
407407
...(isInlineScriptsFeatureEnabled()
408408
? [
409409
commands.registerCommand('python-envs.clearScriptEnvCache', async () => {
410-
await clearScriptEnvironmentCacheCommand(envManagers, projectManager);
410+
await clearScriptEnvironmentCacheCommand(envManagers);
411411
}),
412412
]
413413
: []),

‎src/features/envCommands.ts‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
PythonProjectManager,
3232
} from '../internal.api';
3333
import {
34-
removeInlineScriptPythonProjectSettings,
3534
removePythonProjectSetting,
3635
setEnvironmentManager,
3736
setPackageManager,
@@ -704,7 +703,6 @@ export async function clearEnvironmentCachesCommand(
704703

705704
export async function clearScriptEnvironmentCacheCommand(
706705
em: EnvironmentManagers,
707-
wm: PythonProjectManager,
708706
): Promise<void> {
709707
const manager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID);
710708
if (!manager || !manager.supportsClearCache()) {
@@ -725,11 +723,7 @@ export async function clearScriptEnvironmentCacheCommand(
725723
return;
726724
}
727725

728-
await manager.clearCache();
729-
const loadedProjectsToRemove = await removeInlineScriptPythonProjectSettings(wm.getProjects());
730-
if (loadedProjectsToRemove.length > 0) {
731-
wm.remove(loadedProjectsToRemove);
732-
}
726+
await em.clearInlineScriptCache();
733727
}
734728

735729
export async function getPackageCommandOptions(

‎src/features/envManagers.ts‎

Lines changed: 202 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as path from 'path';
12
import { ConfigurationTarget, Disposable, Event, EventEmitter, Uri, workspace } from 'vscode';
23
import {
34
DidChangeEnvironmentEventArgs,
@@ -43,7 +44,14 @@ import {
4344
EditAllManagerSettings,
4445
getDefaultEnvManagerSetting,
4546
getDefaultPkgManagerSetting,
47+
getExactPythonProjectSetting,
48+
getManagedInlineScriptProjectRegistration,
4649
getProjectEnvironmentManagerSetting,
50+
InlineScriptPythonProjectRegistration,
51+
registerInlineScriptPythonProjectSetting,
52+
removeInlineScriptPythonProjectSettings,
53+
removeManagedInlineScriptPythonProjectSetting,
54+
rollbackInlineScriptPythonProjectSetting,
4755
setAllManagerSettings,
4856
} from './settings/settingHelpers';
4957

@@ -73,6 +81,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
7381
private readonly _inlineRoutingOverrides = new Map<string, string>();
7482
private readonly _selectionRevisions = new Map<string, number>();
7583
private readonly _selectionOperationCounters = new Map<string, number>();
84+
private inlineScriptProjectSelectionQueue: Promise<void> = Promise.resolve();
7685

7786
private _onDidChangeEnvironmentManager = new EventEmitter<DidChangeEnvironmentManagerEventArgs>();
7887
private _onDidChangePackageManager = new EventEmitter<DidChangePackageManagerEventArgs>();
@@ -99,6 +108,15 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
99108
public onDidChangeActiveEnvironment: Event<DidChangeEnvironmentEventArgs> =
100109
this._onDidChangeActiveEnvironment.event;
101110

111+
private enqueueInlineScriptProjectSelection<T>(operation: () => Promise<T>): Promise<T> {
112+
const run = this.inlineScriptProjectSelectionQueue.then(operation);
113+
this.inlineScriptProjectSelectionQueue = run.then(
114+
() => undefined,
115+
() => undefined,
116+
);
117+
return run;
118+
}
119+
102120
constructor(
103121
private readonly pm: PythonProjectManager,
104122
private readonly inlineScriptRouting?: InlineScriptRoutingRegistry,
@@ -343,6 +361,20 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
343361
}
344362
}
345363

364+
public clearInlineScriptCache(): Promise<void> {
365+
return this.enqueueInlineScriptProjectSelection(async () => {
366+
const manager = this._environmentManagers.get(INLINE_SCRIPT_MANAGER_ID);
367+
if (!manager || !manager.supportsClearCache()) {
368+
throw new Error('Inline-script environment manager is unavailable.');
369+
}
370+
await manager.clearCache();
371+
const projectsToRemove = await removeInlineScriptPythonProjectSettings(this.pm.getProjects());
372+
if (projectsToRemove.length > 0) {
373+
this.pm.remove(projectsToRemove);
374+
}
375+
});
376+
}
377+
346378
/**
347379
* Sets the environment for a single scope, scope of undefined checks 'global'.
348380
* If given an array of scopes, delegates to setEnvironments for batch setting.
@@ -353,15 +385,27 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
353385
* Pass `false` when setting environments during initial selection/auto-discovery
354386
* to avoid writing to settings.json.
355387
*/
356-
public async setEnvironment(
388+
public setEnvironment(
357389
scope: SetEnvironmentScope,
358390
environment?: PythonEnvironment,
359391
shouldPersistSettings: boolean = true,
360392
): Promise<void> {
361393
if (Array.isArray(scope)) {
362394
return this.setEnvironments(scope, environment, shouldPersistSettings);
363395
}
396+
if (this.shouldSerializeInlineScriptProjectSelection(scope, environment, shouldPersistSettings)) {
397+
return this.enqueueInlineScriptProjectSelection(() =>
398+
this.setEnvironmentCore(scope, environment, shouldPersistSettings),
399+
);
400+
}
401+
return this.setEnvironmentCore(scope, environment, shouldPersistSettings);
402+
}
364403

404+
private async setEnvironmentCore(
405+
scope: Uri | undefined,
406+
environment: PythonEnvironment | undefined,
407+
shouldPersistSettings: boolean,
408+
): Promise<void> {
365409
const customScope = environment ? environment : scope;
366410
const manager = this.getEnvironmentManager(customScope);
367411
if (!manager) {
@@ -374,6 +418,10 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
374418
traceError(this.managers.map((m) => m.id).join(', '));
375419
return;
376420
}
421+
const inlineRegistration =
422+
scope && environment && manager.id === INLINE_SCRIPT_MANAGER_ID && shouldPersistSettings
423+
? await this.prepareInlineScriptProjectRegistration(scope)
424+
: undefined;
377425
const project = scope ? this.pm.get(scope) : undefined;
378426
const key = this.getActiveSelectionKey(scope, manager, project);
379427
const operation = this.beginSelectionOperation(key);
@@ -393,7 +441,21 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
393441
this.inlineScriptRouting?.shouldRoute(scope)
394442
? this.beginSelectionOperation(this.getInlineScriptSelectionKey(scope))
395443
: undefined;
396-
await manager.set(scope, environment);
444+
try {
445+
await manager.set(scope, environment);
446+
} catch (error) {
447+
if (inlineRegistration) {
448+
await this.rollbackInlineScriptProjectRegistration(inlineRegistration);
449+
}
450+
throw error;
451+
}
452+
453+
if (scope && !environment && manager.id === INLINE_SCRIPT_MANAGER_ID && shouldPersistSettings && project) {
454+
const removeProject = await removeManagedInlineScriptPythonProjectSetting(project);
455+
if (removeProject) {
456+
this.pm.remove(project);
457+
}
458+
}
397459

398460
// Only persist to settings when explicitly requested
399461
if (shouldPersistSettings && scope) {
@@ -482,10 +544,23 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
482544
* Pass `false` when setting environments during initial selection/auto-discovery
483545
* to avoid writing to settings.json.
484546
*/
485-
public async setEnvironments(
547+
public setEnvironments(
486548
scope: Uri[] | string,
487549
environment?: PythonEnvironment,
488550
shouldPersistSettings: boolean = true,
551+
): Promise<void> {
552+
if (this.shouldSerializeInlineScriptProjectSelection(scope, environment, shouldPersistSettings)) {
553+
return this.enqueueInlineScriptProjectSelection(() =>
554+
this.setEnvironmentsCore(scope, environment, shouldPersistSettings),
555+
);
556+
}
557+
return this.setEnvironmentsCore(scope, environment, shouldPersistSettings);
558+
}
559+
560+
private async setEnvironmentsCore(
561+
scope: Uri[] | string,
562+
environment: PythonEnvironment | undefined,
563+
shouldPersistSettings: boolean,
489564
): Promise<void> {
490565
if (environment) {
491566
const manager = this.managers.find((m) => m.id === environment.envId.managerId);
@@ -502,8 +577,17 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
502577
const settings: EditAllManagerSettings[] = [];
503578
const events: DidChangeEnvironmentEventArgs[] = [];
504579
if (Array.isArray(scope) && scope.every((s) => s instanceof Uri)) {
580+
const inlineRegistrations =
581+
manager.id === INLINE_SCRIPT_MANAGER_ID && shouldPersistSettings
582+
? await this.prepareInlineScriptProjectRegistrations(scope)
583+
: [];
505584
const selections = scope.map((uri) => this.beginPendingSelection(uri, manager));
506-
await manager.set(scope, environment);
585+
try {
586+
await manager.set(scope, environment);
587+
} catch (error) {
588+
await this.rollbackInlineScriptProjectRegistrations(inlineRegistrations);
589+
throw error;
590+
}
507591
selections.forEach((selection) => {
508592
const m = this.getEnvironmentManager(selection.scope);
509593
// Always add settings when persisting, OR when manager differs
@@ -518,7 +602,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
518602
});
519603
}
520604
});
521-
if (shouldPersistSettings) {
605+
if (shouldPersistSettings && settings.length > 0) {
522606
await setAllManagerSettings(settings);
523607
}
524608
selections.forEach((selection) => {
@@ -601,6 +685,19 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
601685
const events: DidChangeEnvironmentEventArgs[] = [];
602686
const selections = uris.map((uri) => this.beginPendingSelection(uri, manager));
603687
await manager.set(uris);
688+
if (manager.id === INLINE_SCRIPT_MANAGER_ID && shouldPersistSettings) {
689+
for (const selection of selections) {
690+
if (!selection.project) {
691+
continue;
692+
}
693+
const removeProject = await removeManagedInlineScriptPythonProjectSetting(
694+
selection.project,
695+
);
696+
if (removeProject) {
697+
this.pm.remove(selection.project);
698+
}
699+
}
700+
}
604701
await Promise.all(
605702
selections.map(async (selection) => {
606703
const newEnv = await manager.get(selection.scope);
@@ -767,6 +864,10 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
767864
if (!project || normalizePath(project.uri.fsPath) !== normalizePath(scope.fsPath)) {
768865
return undefined;
769866
}
867+
const exactSetting = getExactPythonProjectSetting(this.pm, scope);
868+
if (getManagedInlineScriptProjectRegistration(exactSetting)) {
869+
return undefined;
870+
}
770871
const exactManagerId = getProjectEnvironmentManagerSetting(this.pm, scope);
771872
return exactManagerId ? this._environmentManagers.get(exactManagerId) : undefined;
772873
}
@@ -977,16 +1078,99 @@ export class PythonEnvironmentManagers implements EnvironmentManagers {
9771078
);
9781079
}
9791080

980-
private canPersistManagerSettingForScope(
1081+
private shouldSerializeInlineScriptProjectSelection(
1082+
scope: SetEnvironmentScope | string,
1083+
environment: PythonEnvironment | undefined,
1084+
shouldPersistSettings: boolean,
1085+
): boolean {
1086+
if (!shouldPersistSettings) {
1087+
return false;
1088+
}
1089+
if (environment?.envId.managerId === INLINE_SCRIPT_MANAGER_ID) {
1090+
return true;
1091+
}
1092+
const scopes = scope instanceof Uri ? [scope] : Array.isArray(scope) ? scope : [];
1093+
return scopes.some((uri) =>
1094+
getManagedInlineScriptProjectRegistration(getExactPythonProjectSetting(this.pm, uri)),
1095+
);
1096+
}
1097+
1098+
private async prepareInlineScriptProjectRegistration(
9811099
scope: Uri,
1100+
): Promise<PreparedInlineScriptProjectRegistration | undefined> {
1101+
const existingProject = this.pm.get(scope);
1102+
const hasExactProject =
1103+
existingProject !== undefined &&
1104+
normalizePath(existingProject.uri.fsPath) === normalizePath(scope.fsPath);
1105+
const project = hasExactProject
1106+
? existingProject
1107+
: this.pm.create(path.basename(scope.fsPath) || scope.fsPath, scope);
1108+
const registration = await registerInlineScriptPythonProjectSetting(this.pm, project);
1109+
if (!registration) {
1110+
return undefined;
1111+
}
1112+
1113+
const addedProject = !hasExactProject;
1114+
try {
1115+
if (addedProject) {
1116+
await this.pm.add(project, { persistSettings: false });
1117+
}
1118+
} catch (error) {
1119+
const removeProject = await rollbackInlineScriptPythonProjectSetting(project, registration);
1120+
if (removeProject && addedProject) {
1121+
this.pm.remove(project);
1122+
}
1123+
throw error;
1124+
}
1125+
return { project, registration, addedProject };
1126+
}
1127+
1128+
private async prepareInlineScriptProjectRegistrations(
1129+
scopes: readonly Uri[],
1130+
): Promise<PreparedInlineScriptProjectRegistration[]> {
1131+
const registrations: PreparedInlineScriptProjectRegistration[] = [];
1132+
try {
1133+
for (const scope of scopes) {
1134+
const registration = await this.prepareInlineScriptProjectRegistration(scope);
1135+
if (registration) {
1136+
registrations.push(registration);
1137+
}
1138+
}
1139+
return registrations;
1140+
} catch (error) {
1141+
await this.rollbackInlineScriptProjectRegistrations(registrations);
1142+
throw error;
1143+
}
1144+
}
1145+
1146+
private async rollbackInlineScriptProjectRegistrations(
1147+
registrations: readonly PreparedInlineScriptProjectRegistration[],
1148+
): Promise<void> {
1149+
for (const registration of [...registrations].reverse()) {
1150+
await this.rollbackInlineScriptProjectRegistration(registration);
1151+
}
1152+
}
1153+
1154+
private async rollbackInlineScriptProjectRegistration(
1155+
prepared: PreparedInlineScriptProjectRegistration,
1156+
): Promise<void> {
1157+
const removeProject = await rollbackInlineScriptPythonProjectSetting(
1158+
prepared.project,
1159+
prepared.registration,
1160+
);
1161+
if (removeProject && prepared.addedProject) {
1162+
this.pm.remove(prepared.project);
1163+
}
1164+
}
1165+
1166+
private canPersistManagerSettingForScope(
1167+
_scope: Uri,
9821168
manager: InternalEnvironmentManager,
983-
project: PythonProject | undefined,
1169+
_project: PythonProject | undefined,
9841170
): boolean {
985-
// Inline associations are per file; never promote one to its containing project's manager setting.
986-
return (
987-
manager.id !== INLINE_SCRIPT_MANAGER_ID ||
988-
(!!project && normalizePath(project.uri.fsPath) === normalizePath(scope.fsPath))
989-
);
1171+
// Inline associations are persisted by the inline manager. The managed exact-project entry
1172+
// stores the ordinary fallback manager rather than replacing it with the inline manager.
1173+
return manager.id !== INLINE_SCRIPT_MANAGER_ID;
9901174
}
9911175

9921176
private beginSelectionOperation(key: string): number {
@@ -1055,3 +1239,9 @@ interface PendingEnvironmentSelection {
10551239
readonly publishInlineSelection: boolean;
10561240
readonly inlineClearOperation: number | undefined;
10571241
}
1242+
1243+
interface PreparedInlineScriptProjectRegistration {
1244+
readonly project: PythonProject;
1245+
readonly registration: InlineScriptPythonProjectRegistration;
1246+
readonly addedProject: boolean;
1247+
}

‎src/features/projectManager.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,10 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
197197
return new PythonProjectsImpl(name, uri, options);
198198
}
199199

200-
async add(projects: PythonProject | ProjectArray): Promise<void> {
200+
async add(
201+
projects: PythonProject | ProjectArray,
202+
options?: { persistSettings?: boolean },
203+
): Promise<void> {
201204
const _projects = Array.isArray(projects) ? projects : [projects];
202205
if (_projects.length === 0) {
203206
return;
@@ -228,7 +231,7 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
228231
});
229232
this._onDidChangeProjects.fire(Array.from(this._projects.values()));
230233

231-
if (edits.length > 0) {
234+
if (options?.persistSettings !== false && edits.length > 0) {
232235
await addPythonProjectSetting(edits);
233236
}
234237
}

0 commit comments

Comments
 (0)