Skip to content

Commit f5138f4

Browse files
authored
Make ms-python.python a soft dependency (#123)
* Make ms-python.python a soft dependency - Remove `ms-python.python` from `extensionDependencies` so the extension can install in VS Code derivatives (e.g. Cursor) whose marketplaces cannot resolve the dependency at install time. - Handle the missing Python extension gracefully at runtime: log a warning on startup and show a user-facing error when SDK discovery is attempted without it. When `ms-python.python` is installed, behavior is unchanged. * Complete soft Python extension dependency Builds on 0313b67 (which removed the manifest dep) so the rest of the extension behaves gracefully when ms-python.python is absent. - Run monorepo SDK detection before the Python extension check, so .derived/ workflows succeed without the Python extension. - Surface missing Python extension via the status bar with a click-to- marketplace action, replacing the modal error. - React to the Python extension being installed mid-session via vscode.extensions.onDidChange, avoiding a required window reload. - Install ms-python.python in the pixi vscode-test config; previously pulled in transitively via extensionDependencies. - README: scope the Python extension's role to pixi/wheel SDK discovery rather than implying a hard requirement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add SDK path override setting Addresses a reviewer concern that uninstalling the Python extension (or being unable to install it) silently breaks SDK discovery. The new `mojo.sdk.path` setting lets users point the extension at a specific SDK installation regardless of auto-detection. - New `mojo.sdk.path` setting (window-scoped) declared with a Markdown description covering pixi/conda and wheel layouts. - Detection priority: override → monorepo → Python extension. When the override is set but invalid, the extension surfaces an error instead of silently falling back to a different SDK. - Refactor `createSDKFromWheelEnv` to a primitive-args `createSDKFromWheelLayout(sysPrefix, major, minor, kind)` so the same logic serves Python-ext envs and override paths. For overrides, the Python version is detected by globbing `lib/python*` since the Python extension may not be available. - Status bar: new `invalid-sdk-override` error state; clicking opens the setting. - Replace the dead `SDK.additionalSDKs` configWatcher entry with `sdk.path` (the dead key was watched but never declared or read). - README: priority order, override subsection with examples, scope note.
1 parent 7a5d05e commit f5138f4

6 files changed

Lines changed: 277 additions & 22 deletions

File tree

.vscode-test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ export default defineConfig([
2121
label: 'pixi',
2222
workspaceFolder: 'fixtures/pixi-workspace/',
2323
files: 'out/**/*.test.pixi.js',
24+
installExtensions: ['ms-python.python'],
2425
},
2526
]);

README.md

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,52 @@ This VS Code extension from the Modular team adds support for the
2525

2626
### Mojo SDK resolution
2727

28-
The extension relies on the Python extension for locating your Python
29-
environment. In some cases, this appears to default to your globally-installed
30-
environment, even when a virtual environment exists.
31-
3228
When the extension detects a Mojo project (a workspace containing `.mojo`
3329
files, or with a `.mojo` file open), the SDK status appears in the bottom-left
3430
status bar, showing details of the detected SDK or a clickable notice if no
3531
SDK was detected.
3632

33+
The extension resolves the active SDK in this priority order:
34+
35+
1. **`mojo.sdk.path` setting** — if set, the extension uses this path as an
36+
explicit override and does not fall back to auto-detection. See below.
37+
2. **Monorepo SDK** — a `.derived/` directory in the open workspace folder.
38+
3. **Python environment** — discovered via the [Python extension for VS
39+
Code](https://marketplace.visualstudio.com/items?itemName=ms-python.python),
40+
used to locate SDKs installed in pixi or wheel-based environments.
41+
42+
If the Python extension is not installed, the status bar will prompt you to
43+
install it; clicking the prompt opens it in the marketplace.
44+
45+
#### `mojo.sdk.path` override
46+
47+
For environments where the Python extension is unavailable or auto-detection
48+
picks the wrong environment, you can set `mojo.sdk.path` to point at a Mojo
49+
SDK directly. The setting is available in both the Settings UI and
50+
`settings.json`, and can be set at either user or workspace scope — workspace
51+
scope is usually the right choice, since different projects typically use
52+
different SDKs.
53+
54+
The value must be an absolute path to an environment root:
55+
56+
- **For pixi or conda installs**, point to the environment root that contains
57+
`share/max/modular.cfg` — for example,
58+
`/path/to/workspace/.pixi/envs/default`.
59+
- **For wheel installs**, point to the environment root that contains
60+
`bin/mojo` and `lib/python*/site-packages/modular/` — for example,
61+
`/path/to/.venv`.
62+
63+
When set, this override beats every other detection source. If the path is
64+
invalid, the extension will surface an error in the status bar rather than
65+
silently falling back to auto-detection.
66+
67+
#### Troubleshooting
68+
3769
If the Mojo extension cannot find your SDK installation, try invoking the
38-
`Python: Select Interpreter` command and selecting your virtual
39-
environment.
70+
`Python: Select Interpreter` command and selecting the environment that
71+
contains your Mojo SDK. In some cases, the Python extension defaults to your
72+
globally-installed environment even when a workspace-local one exists. If
73+
that doesn't help, set `mojo.sdk.path` directly.
4074

4175
## Debugger
4276

extension/extension.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,15 @@ Activating the Mojo Extension
8686
const updateStatusBar = async () => {
8787
statusBar.showLoading();
8888
const sdk = await this.pyenvManager!.findActiveSDK();
89-
statusBar.update(sdk);
89+
let reason;
90+
if (!sdk) {
91+
if (this.pyenvManager!.getOverridePathState() === 'invalid') {
92+
reason = 'invalid-sdk-override' as const;
93+
} else if (!this.pyenvManager!.isPythonExtensionAvailable()) {
94+
reason = 'no-python-extension' as const;
95+
}
96+
}
97+
statusBar.update(sdk, reason);
9098
};
9199

92100
this.pushSubscription(
@@ -99,7 +107,7 @@ Activating the Mojo Extension
99107

100108
this.pushSubscription(
101109
await configWatcher.activate({
102-
settings: ['SDK.additionalSDKs'],
110+
settings: ['sdk.path'],
103111
}),
104112
);
105113

extension/pyenv.ts

Lines changed: 184 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import * as vscode from 'vscode';
1515
import * as ini from 'ini';
1616
import { DisposableContext } from './utils/disposableContext';
1717
import { PythonExtension, ResolvedEnvironment } from '@vscode/python-extension';
18-
import assert from 'assert';
1918
import { Logger } from './logging';
2019
import path from 'path';
2120
import * as util from 'util';
@@ -25,7 +24,8 @@ import {
2524
} from 'child_process';
2625
import { Memoize } from 'typescript-memoize';
2726
import { TelemetryReporter } from './telemetry';
28-
import { fileExists } from './utils/files';
27+
import { directoryExists, fileExists } from './utils/files';
28+
import * as config from './utils/config';
2929
const execFile = util.promisify(callbackExecFile);
3030
const exec = util.promisify(callbackExec);
3131

@@ -141,6 +141,8 @@ class HomeSDK extends SDK {
141141
}
142142
}
143143

144+
export type OverridePathState = 'unset' | 'valid' | 'invalid';
145+
144146
export class PythonEnvironmentManager extends DisposableContext {
145147
private api: PythonExtension | undefined = undefined;
146148
private logger: Logger;
@@ -150,6 +152,7 @@ export class PythonEnvironmentManager extends DisposableContext {
150152
private displayedSDKError: boolean = false;
151153
private lastLoadedEnv: string | undefined = undefined;
152154
private activeSDK: SDK | undefined = undefined;
155+
private overridePathState: OverridePathState = 'unset';
153156

154157
constructor(logger: Logger, reporter: TelemetryReporter) {
155158
super();
@@ -160,14 +163,56 @@ export class PythonEnvironmentManager extends DisposableContext {
160163
}
161164

162165
public async init() {
163-
this.api = await PythonExtension.api();
166+
await this.tryInitApi();
167+
// Watch for the Python extension being installed/enabled mid-session so
168+
// we can pick it up without requiring a window reload.
169+
this.pushSubscription(
170+
vscode.extensions.onDidChange(() => this.handleExtensionChange()),
171+
);
172+
}
173+
174+
private async tryInitApi() {
175+
if (this.api) {
176+
return;
177+
}
178+
if (!vscode.extensions.getExtension('ms-python.python')) {
179+
this.logger.warn(
180+
'The Python extension is not installed. ' +
181+
'Install the Python extension (ms-python.python) to enable automatic SDK discovery.',
182+
);
183+
return;
184+
}
185+
try {
186+
this.api = await PythonExtension.api();
187+
} catch (e) {
188+
this.logger.warn('Failed to load the Python extension API:', e);
189+
return;
190+
}
164191
this.pushSubscription(
165192
this.api.environments.onDidChangeActiveEnvironmentPath((p) =>
166193
this.handleEnvironmentChange(p.path),
167194
),
168195
);
169196
}
170197

198+
private async handleExtensionChange() {
199+
if (this.api) {
200+
return;
201+
}
202+
if (!vscode.extensions.getExtension('ms-python.python')) {
203+
return;
204+
}
205+
this.logger.info(
206+
'Python extension became available, initializing SDK discovery.',
207+
);
208+
await this.tryInitApi();
209+
if (this.api) {
210+
// Reset error gating and notify subscribers so the status bar refreshes.
211+
this.displayedSDKError = false;
212+
this.envChangeEmitter.fire();
213+
}
214+
}
215+
171216
private async handleEnvironmentChange(newEnv: string) {
172217
this.logger.debug(
173218
`Active environment path change: ${newEnv} (current: ${this.lastLoadedEnv})`,
@@ -179,10 +224,35 @@ export class PythonEnvironmentManager extends DisposableContext {
179224
}
180225
}
181226

182-
/// Finds the active SDK from the currently active Python environment, or undefined if one is not present.
227+
/// Whether the Python extension API is available. Used by the status bar to
228+
/// distinguish "no SDK found" from "Python extension not installed".
229+
public isPythonExtensionAvailable(): boolean {
230+
return this.api !== undefined;
231+
}
232+
233+
/// State of the `mojo.sdk.path` override setting. Used by the status bar to
234+
/// distinguish "user set an invalid override path" from other failure modes.
235+
public getOverridePathState(): OverridePathState {
236+
return this.overridePathState;
237+
}
238+
239+
/// Finds the active SDK, in priority order:
240+
/// 1. `mojo.sdk.path` override (if set; fails loudly without falling back)
241+
/// 2. Monorepo `.derived/` SDK
242+
/// 3. SDK from the active Python extension environment
183243
public async findActiveSDK(): Promise<SDK | undefined> {
184-
assert(this.api !== undefined);
185-
// Prioritize retrieving a monorepo SDK over querying the environment.
244+
// 1. User-supplied override path beats every other source. If it's set
245+
// but doesn't resolve, do NOT fall back — that would silently violate
246+
// the override semantics. The status bar surfaces the failure instead.
247+
const overrideSDK = await this.tryGetOverrideSDK();
248+
if (overrideSDK) {
249+
return overrideSDK;
250+
}
251+
if (this.overridePathState === 'invalid') {
252+
return undefined;
253+
}
254+
255+
// 2. Monorepo SDK — works without the Python extension.
186256
const monorepoSDK = await this.tryGetMonorepoSDK();
187257

188258
if (monorepoSDK) {
@@ -192,6 +262,13 @@ export class PythonEnvironmentManager extends DisposableContext {
192262
return monorepoSDK;
193263
}
194264

265+
if (!this.api) {
266+
this.logger.warn(
267+
'Cannot discover SDK: the Python extension (ms-python.python) is not installed.',
268+
);
269+
return undefined;
270+
}
271+
195272
const envPath = this.api.environments.getActiveEnvironmentPath();
196273
const env = await this.api.environments.resolveEnvironment(envPath);
197274
this.logger.info('Loading MAX SDK information from Python environment');
@@ -255,11 +332,28 @@ export class PythonEnvironmentManager extends DisposableContext {
255332
private async createSDKFromWheelEnv(
256333
env: ResolvedEnvironment,
257334
): Promise<SDK | undefined> {
258-
const binPath = path.join(env.executable.sysPrefix, 'bin');
259-
const libPath = path.join(
335+
return this.createSDKFromWheelLayout(
260336
env.executable.sysPrefix,
337+
env.version!.major,
338+
env.version!.minor,
339+
SDKKind.Environment,
340+
);
341+
}
342+
343+
/// Create an SDK from a wheel-style layout rooted at `sysPrefix`, given
344+
/// a known Python major/minor version. Used both for Python-extension envs
345+
/// and user-supplied override paths.
346+
private async createSDKFromWheelLayout(
347+
sysPrefix: string,
348+
pythonMajor: number,
349+
pythonMinor: number,
350+
kind: SDKKind,
351+
): Promise<SDK | undefined> {
352+
const binPath = path.join(sysPrefix, 'bin');
353+
const libPath = path.join(
354+
sysPrefix,
261355
'lib',
262-
`python${env.version!.major}.${env.version!.minor}`,
356+
`python${pythonMajor}.${pythonMinor}`,
263357
'site-packages',
264358
'modular',
265359
'lib',
@@ -313,7 +407,7 @@ export class PythonEnvironmentManager extends DisposableContext {
313407
const versionResult = await exec(`"${mojoPath}" --version`);
314408
return new SDK(
315409
this.logger,
316-
SDKKind.Environment,
410+
kind,
317411
versionResult.stdout,
318412
lspPath,
319413
mblackPath,
@@ -398,6 +492,86 @@ export class PythonEnvironmentManager extends DisposableContext {
398492
}
399493
}
400494

495+
/// Attempt to load an SDK from the user-supplied `mojo.sdk.path` setting.
496+
/// Updates `overridePathState` as a side effect so callers can distinguish
497+
/// "no override set" from "override set but unusable".
498+
private async tryGetOverrideSDK(): Promise<SDK | undefined> {
499+
const overridePath = config.get<string>('sdk.path', undefined)?.trim();
500+
if (!overridePath) {
501+
this.overridePathState = 'unset';
502+
return undefined;
503+
}
504+
505+
this.logger.info(`Loading SDK from override path: ${overridePath}`);
506+
507+
if (!(await directoryExists(overridePath))) {
508+
this.logger.error(
509+
`Override path '${overridePath}' does not exist or is not a directory.`,
510+
);
511+
this.overridePathState = 'invalid';
512+
return undefined;
513+
}
514+
515+
// Try the conda/pixi layout first: <override>/share/max/modular.cfg
516+
const homePath = path.join(overridePath, 'share', 'max');
517+
if (await fileExists(path.join(homePath, 'modular.cfg'))) {
518+
const sdk = await this.createSDKFromHomePath(
519+
SDKKind.Custom,
520+
homePath,
521+
overridePath,
522+
);
523+
this.overridePathState = sdk ? 'valid' : 'invalid';
524+
return sdk;
525+
}
526+
527+
// Fall back to the wheel layout: <override>/lib/python<X>.<Y>/site-packages/modular/...
528+
const pythonVersion = await this.detectPythonVersion(overridePath);
529+
if (pythonVersion) {
530+
const [major, minor] = pythonVersion;
531+
const sdk = await this.createSDKFromWheelLayout(
532+
overridePath,
533+
major,
534+
minor,
535+
SDKKind.Custom,
536+
);
537+
this.overridePathState = sdk ? 'valid' : 'invalid';
538+
return sdk;
539+
}
540+
541+
this.logger.error(
542+
`Override path '${overridePath}' contains neither a 'share/max/modular.cfg' file nor a 'lib/python*' directory.`,
543+
);
544+
this.overridePathState = 'invalid';
545+
return undefined;
546+
}
547+
548+
/// Find a `python<major>.<minor>` directory under `<root>/lib`, returning
549+
/// the parsed version. Used to resolve wheel-style install paths whose
550+
/// Python version we can't query directly (no Python extension available).
551+
private async detectPythonVersion(
552+
root: string,
553+
): Promise<[number, number] | undefined> {
554+
const libDir = path.join(root, 'lib');
555+
let entries: [string, vscode.FileType][];
556+
try {
557+
entries = await vscode.workspace.fs.readDirectory(
558+
vscode.Uri.file(libDir),
559+
);
560+
} catch {
561+
return undefined;
562+
}
563+
for (const [name, type] of entries) {
564+
if (!(type & vscode.FileType.Directory)) {
565+
continue;
566+
}
567+
const match = name.match(/^python(\d+)\.(\d+)$/);
568+
if (match) {
569+
return [parseInt(match[1], 10), parseInt(match[2], 10)];
570+
}
571+
}
572+
return undefined;
573+
}
574+
401575
/// Attempt to load a monorepo SDK from the currently open workspace folder.
402576
/// Resolves with the loaded SDK, or undefined if one doesn't exist.
403577
private async tryGetMonorepoSDK(): Promise<SDK | undefined> {

0 commit comments

Comments
 (0)