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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
},
"codecarbon.interpreter": {
"default": "",
"description": "Python executable path. Leave empty to use system default.",
"description": "Python executable path. Leave empty to auto-discover from Python extension (if available), then system commands (python3/python on macOS/Linux, python/py on Windows).",
"type": "string"
},
"codecarbon.autoInstall": {
Expand Down
47 changes: 47 additions & 0 deletions src/services/interpreterCandidates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export function buildInterpreterCandidates(
configuredPath: string,
pythonExtensionPath: string | undefined,
preferPythonExtension: boolean,
): string[] {
const seen = new Set<string>();
const candidates: string[] = [];

const add = (value: string | undefined): void => {
const normalized = (value ?? '').trim();
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
candidates.push(normalized);
};

if (preferPythonExtension) {
add(pythonExtensionPath);
add(configuredPath);
} else {
add(configuredPath);
add(pythonExtensionPath);
}

if (process.platform === 'win32') {
if (seen.has('python3')) {
add('python');
add('py');
}
if (seen.has('python')) {
add('py');
}
if (seen.has('py')) {
add('python');
}
return candidates;
}

if (seen.has('python3')) {
add('python');
}
if (seen.has('python')) {
add('python3');
}
return candidates;
}
83 changes: 83 additions & 0 deletions src/services/interpreterResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import * as vscode from 'vscode';
import { LogService } from './logService';
import { buildInterpreterCandidates } from './interpreterCandidates';

type PythonEnvironmentApi = {
getActiveEnvironmentPath?: (resource?: vscode.Uri) => Promise<unknown>;
};

type PythonExtensionApi = {
environments?: PythonEnvironmentApi;
};

export class InterpreterResolver {
private logService: LogService;

constructor() {
this.logService = LogService.getInstance();
}

public async getCandidates(configuredPath: string, preferPythonExtension: boolean): Promise<string[]> {
const pythonExtensionPath = await this.resolveFromPythonExtension();
const candidates = buildInterpreterCandidates(configuredPath, pythonExtensionPath, preferPythonExtension);

this.logService.log(`Interpreter candidates: ${candidates.join(', ')}`);
return candidates;
}

private async resolveFromPythonExtension(): Promise<string | undefined> {
const extension = vscode.extensions.getExtension('ms-python.python');
if (!extension) {
return undefined;
}

const workspaceUri = vscode.workspace.workspaceFolders?.[0]?.uri;
try {
const api = (await extension.activate()) as PythonExtensionApi;
const active = await api.environments?.getActiveEnvironmentPath?.(workspaceUri);
const value = this.readPathLikeValue(active);
if (value) {
this.logService.log(`Interpreter resolved from Python extension API: ${value}`);
return value;
}
} catch (error) {
this.logService.logWarning(`Python extension API interpreter resolution failed: ${String(error)}`);
}

const config = vscode.workspace.getConfiguration('python', workspaceUri);
const settingPath = this.cleanConfiguredPath(
config.get<string>('defaultInterpreterPath') ?? config.get<string>('pythonPath'),
);
if (settingPath) {
this.logService.log(`Interpreter resolved from Python extension settings: ${settingPath}`);
return settingPath;
}
return undefined;
}

private readPathLikeValue(value: unknown): string | undefined {
if (typeof value === 'string') {
return this.cleanConfiguredPath(value);
}
if (!value || typeof value !== 'object') {
return undefined;
}
const record = value as Record<string, unknown>;
if (typeof record.path === 'string') {
return this.cleanConfiguredPath(record.path);
}
return undefined;
}

private cleanConfiguredPath(value: string | undefined): string | undefined {
const normalized = (value ?? '').trim();
if (!normalized) {
return undefined;
}
// Ignore unresolved command variables from settings.
if (normalized.startsWith('${command:')) {
return undefined;
}
return normalized;
}
}
11 changes: 11 additions & 0 deletions src/services/interpreterSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export async function selectWorkingInterpreter(
candidates: string[],
probe: (candidate: string) => Promise<boolean>,
): Promise<string | null> {
for (const candidate of candidates) {
if (await probe(candidate)) {
return candidate;
}
}
return null;
}
70 changes: 52 additions & 18 deletions src/services/pythonService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import { execFile } from 'child_process';
import { LogService } from './logService';
import { NotificationService } from './notificationService';
import { InterpreterResolver } from './interpreterResolver';
import { selectWorkingInterpreter } from './interpreterSelection';
import { ConfigService } from '../utils/config';
import { INSTALL_STRATEGIES, MESSAGES, PYTHON_PACKAGE_NAME } from '../utils/constants';

Expand All @@ -25,6 +27,10 @@ interface RuntimePreflight {
details: string[];
}

interface ResolvedRuntime {
pythonPath: string;
}

interface ExecResult {
ok: boolean;
stdout: string;
Expand All @@ -42,12 +48,14 @@ interface InstallCounters {
export class PythonService {
private static readonly MIN_SUPPORTED_PYTHON_MINOR = 8;
private logService: LogService;
private interpreterResolver: InterpreterResolver;
private installerState: InstallerState = 'not_installed';
private counters: InstallCounters = { attempts: 0, success: 0, failure: 0, lastFailure: 'none' };
private startupHealthChecked = false;

constructor() {
this.logService = LogService.getInstance();
this.interpreterResolver = new InterpreterResolver();
}

public getInstallerState(): InstallerState {
Expand All @@ -63,60 +71,63 @@ export class PythonService {
}
this.startupHealthChecked = true;

const preflight = await this.runRuntimePreflight(pythonPath);
if (!preflight.ok) {
const runtime = await this.resolveRuntime(pythonPath);
if (!runtime) {
this.logService.logWarning('Startup health check: Python preflight failed.');
return;
}

const installed = await this.isPackageInstalled(pythonPath, PYTHON_PACKAGE_NAME);
const installed = await this.isPackageInstalled(runtime.pythonPath, PYTHON_PACKAGE_NAME);
this.installerState = installed ? 'installed' : 'not_installed';
this.logService.log(`Startup health check: installer_state=${this.installerState}`);
}

/**
* Ensure runtime is ready and codecarbon package is available before tracking starts.
*/
public async ensureCodecarbonInstalled(pythonPath: string): Promise<boolean> {
public async ensureCodecarbonInstalled(pythonPath: string): Promise<string | null> {
this.logService.log(`Preparing Python runtime: ${pythonPath}`);
const preflight = await this.runRuntimePreflight(pythonPath);
if (!preflight.ok) {
const runtime = await this.resolveRuntime(pythonPath);
if (!runtime) {
NotificationService.showError(MESSAGES.PREFLIGHT_FAILED);
return false;
return null;
}
const resolvedPythonPath = runtime.pythonPath;

const isInstalled = await this.isPackageInstalled(pythonPath, PYTHON_PACKAGE_NAME);
const isInstalled = await this.isPackageInstalled(resolvedPythonPath, PYTHON_PACKAGE_NAME);
if (isInstalled) {
this.installerState = 'installed';
return true;
return resolvedPythonPath;
}

this.installerState = 'not_installed';
if (!ConfigService.isAutoInstallEnabled()) {
NotificationService.showWarning(MESSAGES.INSTALL_DISABLED);
return false;
return null;
}

return this.installOrRepairCodecarbon(pythonPath, true);
const installedNow = await this.installOrRepairCodecarbon(resolvedPythonPath, true);
return installedNow ? resolvedPythonPath : null;
}

/**
* Explicit command entrypoint to install/repair codecarbon package.
*/
public async installOrRepairCodecarbon(pythonPath: string, silentSuccess = false): Promise<boolean> {
const preflight = await this.runRuntimePreflight(pythonPath);
if (!preflight.ok) {
const runtime = await this.resolveRuntime(pythonPath);
if (!runtime) {
NotificationService.showError(MESSAGES.PREFLIGHT_FAILED);
return false;
}
const resolvedPythonPath = runtime.pythonPath;

this.installerState = 'installing';
this.counters.attempts += 1;
this.logService.log(`Installer state=${this.installerState}, strategy=${ConfigService.getInstallStrategy()}`);

const strategies = this.resolveInstallStrategies();
for (const args of strategies) {
const result = await this.execPython(pythonPath, ['-m', 'pip', 'install', ...args, PYTHON_PACKAGE_NAME]);
const result = await this.execPython(resolvedPythonPath, ['-m', 'pip', 'install', ...args, PYTHON_PACKAGE_NAME]);
this.logPipAttempt(args, result);
if (result.ok) {
this.installerState = 'installed';
Expand Down Expand Up @@ -144,13 +155,14 @@ export class PythonService {
* Check codecarbon version and display information.
*/
public async checkCodecarbonVersion(pythonPath: string): Promise<void> {
const preflight = await this.runRuntimePreflight(pythonPath);
if (!preflight.ok) {
const runtime = await this.resolveRuntime(pythonPath);
if (!runtime) {
NotificationService.showError(MESSAGES.PREFLIGHT_FAILED);
return;
}
const resolvedPythonPath = runtime.pythonPath;

const result = await this.execPython(pythonPath, ['-m', 'pip', 'show', PYTHON_PACKAGE_NAME]);
const result = await this.execPython(resolvedPythonPath, ['-m', 'pip', 'show', PYTHON_PACKAGE_NAME]);
if (!result.ok) {
NotificationService.showWarning(MESSAGES.CHECK_VERSION_NOT_INSTALLED);
this.logService.log(MESSAGES.NOT_INSTALLED);
Expand All @@ -169,7 +181,29 @@ export class PythonService {
NotificationService.showInfo(`Codecarbon ${version} is installed`);
this.logService.log(`Codecarbon version: ${version}`);
this.logService.log(`Installation location: ${location}`);
this.logService.log(`Python interpreter: ${pythonPath}`);
this.logService.log(`Python interpreter: ${resolvedPythonPath}`);
}

private async resolveRuntime(pythonPath: string): Promise<ResolvedRuntime | null> {
const hasExplicitPythonPath = ConfigService.hasExplicitPythonPath();
const preferPythonExtension = !hasExplicitPythonPath;
const candidates = await this.interpreterResolver.getCandidates(pythonPath, preferPythonExtension);
const selected = await selectWorkingInterpreter(candidates, async (candidate) => {
const preflight = await this.runRuntimePreflight(candidate);
return preflight.ok;
});
if (!selected) {
return null;
}
if (selected !== pythonPath) {
if (hasExplicitPythonPath) {
this.logService.logWarning(
`Configured interpreter failed; using fallback interpreter instead: ${selected} (configured: ${pythonPath})`,
);
}
this.logService.log(`Python runtime fallback selected: ${selected} (configured: ${pythonPath})`);
}
return { pythonPath: selected };
}

private async runRuntimePreflight(pythonPath: string): Promise<RuntimePreflight> {
Expand Down
6 changes: 3 additions & 3 deletions src/services/trackerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export class TrackerService {

try {
// Ensure codecarbon is installed
const isInstalled = await this.pythonService.ensureCodecarbonInstalled(pythonPath);
if (!isInstalled) {
const resolvedPythonPath = await this.pythonService.ensureCodecarbonInstalled(pythonPath);
if (!resolvedPythonPath) {
return false;
}

Expand All @@ -83,7 +83,7 @@ export class TrackerService {
if (emissionsFilePath) {
trackerArgs.push('--emissions-file', emissionsFilePath);
}
this.pythonProcess = spawn(pythonPath, trackerArgs, workspacePath ? { cwd: workspacePath } : undefined);
this.pythonProcess = spawn(resolvedPythonPath, trackerArgs, workspacePath ? { cwd: workspacePath } : undefined);
if (workspacePath && this.pythonProcess.pid) {
await this.writePidFile(workspacePath, this.pythonProcess.pid);
}
Expand Down
11 changes: 11 additions & 0 deletions src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ export class ConfigService {
return resolvePythonPath(rawInterpreter);
}

public static hasExplicitPythonPath(): boolean {
const config = this.getConfiguration();
const inspection = config.inspect<string>(CONFIGURATION_KEYS.INTERPRETER);
const values = [
inspection?.globalValue,
inspection?.workspaceValue,
inspection?.workspaceFolderValue,
];
return values.some((value) => typeof value === 'string' && value.trim().length > 0);
}

/**
* Check if launch on startup is enabled
*/
Expand Down
5 changes: 4 additions & 1 deletion src/utils/configHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ export function resolvePythonPath(interpreter: unknown): string {
if (typeof interpreter === 'string' && interpreter.trim()) {
return interpreter.trim();
}
return 'python';
if (process.platform === 'win32') {
return 'python';
}
return 'python3';
}

export function resolveLaunchOnStartup(
Expand Down
7 changes: 4 additions & 3 deletions tests/ts/configService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ test('resolvePythonPath returns configured interpreter string', () => {
assert.equal(resolvePythonPath('/usr/local/bin/python3'), '/usr/local/bin/python3');
});

test('resolvePythonPath falls back to python when empty', () => {
assert.equal(resolvePythonPath(''), 'python');
assert.equal(resolvePythonPath(undefined), 'python');
test('resolvePythonPath falls back to platform default when empty', () => {
const expected = process.platform === 'win32' ? 'python' : 'python3';
assert.equal(resolvePythonPath(''), expected);
assert.equal(resolvePythonPath(undefined), expected);
});

test('resolveLaunchOnStartup uses config getter default', () => {
Expand Down
Loading