Skip to content

Commit b3c4c1d

Browse files
committed
fix: ensure path input to copilot tools correctly converted to URI
1 parent 71a0cf3 commit b3c4c1d

File tree

5 files changed

+190
-21
lines changed

5 files changed

+190
-21
lines changed

src/common/utils/pathUtils.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,26 @@ export function normalizePath(path: string): string {
2626
}
2727
return path1;
2828
}
29+
30+
export function getResourceUri(resourcePath: string, root?: string): Uri | undefined {
31+
try {
32+
if (!resourcePath) {
33+
return undefined;
34+
}
35+
36+
const normalizedPath = normalizePath(resourcePath);
37+
if (normalizedPath.includes('://')) {
38+
return Uri.parse(normalizedPath);
39+
}
40+
41+
const path = require('path');
42+
if (!path.isAbsolute(resourcePath) && root) {
43+
const absolutePath = path.resolve(root, resourcePath);
44+
return Uri.file(absolutePath);
45+
}
46+
47+
return Uri.file(resourcePath);
48+
} catch (_err) {
49+
return undefined;
50+
}
51+
}

src/features/copilotTools.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@ import {
1515
PythonPackageGetterApi,
1616
PythonPackageManagementApi,
1717
PythonProjectEnvironmentApi,
18+
PythonProjectGetterApi,
1819
} from '../api';
1920
import { createDeferred } from '../common/utils/deferred';
2021
import { EnvironmentManagers } from '../internal.api';
22+
import { getResourceUri } from '../common/utils/pathUtils';
2123

2224
export interface IResourceReference {
2325
resourcePath?: string;
@@ -35,7 +37,7 @@ interface EnvironmentInfo {
3537
*/
3638
export class GetEnvironmentInfoTool implements LanguageModelTool<IResourceReference> {
3739
constructor(
38-
private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi,
40+
private readonly api: PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonProjectGetterApi,
3941
private readonly envManagers: EnvironmentManagers,
4042
) {}
4143
/**
@@ -59,7 +61,13 @@ export class GetEnvironmentInfoTool implements LanguageModelTool<IResourceRefere
5961
if (parameters.resourcePath === undefined || parameters.resourcePath === '') {
6062
throw new Error('Invalid input: resourcePath is required');
6163
}
62-
const resourcePath: Uri = Uri.parse(parameters.resourcePath);
64+
65+
const projects = this.api.getPythonProjects();
66+
let root = projects.length > 0 ? projects[0].uri.fsPath : undefined;
67+
const resourcePath: Uri | undefined = getResourceUri(parameters.resourcePath, root);
68+
if (!resourcePath) {
69+
throw new Error('Invalid input: Unable to resolve resource path');
70+
}
6371

6472
// environment info set to default values
6573
const envInfo: EnvironmentInfo = {

src/managers/builtin/venvManager.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,11 @@ import * as path from 'path';
3636
import { NativePythonFinder } from '../common/nativePythonFinder';
3737
import { PYTHON_EXTENSION_ID } from '../../common/constants';
3838
import { createDeferred, Deferred } from '../../common/utils/deferred';
39-
import { getLatest, shortVersion, sortEnvironments } from '../common/utils';
39+
import { getLatest, isWindows, shortVersion, sortEnvironments } from '../common/utils';
4040
import { withProgress } from '../../common/window.apis';
4141
import { VenvManagerStrings } from '../../common/localize';
4242
import { showErrorMessage } from '../../common/errors/utils';
43+
import { normalizePath } from '../../common/utils/pathUtils';
4344

4445
export class VenvManager implements EnvironmentManager {
4546
private collection: PythonEnvironment[] = [];
@@ -201,12 +202,21 @@ export class VenvManager implements EnvironmentManager {
201202
const changed: Uri[] = [];
202203
this.fsPathToEnv.forEach((env, uri) => {
203204
if (env.environmentPath.fsPath === environment.environmentPath.fsPath) {
204-
this.fsPathToEnv.delete(uri);
205+
this.deleteFsPathToEnv(uri);
205206
changed.push(Uri.file(uri));
206207
}
207208
});
208209
return changed;
209210
}
211+
private setFsPathToEnv(fsPath: string, environment: PythonEnvironment): void {
212+
this.fsPathToEnv.set(isWindows() ? normalizePath(fsPath) : fsPath, environment);
213+
}
214+
private getFsPathToEnv(fsPath: string): PythonEnvironment | undefined {
215+
return this.fsPathToEnv.get(isWindows() ? normalizePath(fsPath) : fsPath);
216+
}
217+
private deleteFsPathToEnv(fsPath: string): void {
218+
this.fsPathToEnv.delete(isWindows() ? normalizePath(fsPath) : fsPath);
219+
}
210220

211221
async refresh(scope: RefreshEnvironmentsScope): Promise<void> {
212222
return this.internalRefresh(scope, true, VenvManagerStrings.venvRefreshing);
@@ -262,7 +272,7 @@ export class VenvManager implements EnvironmentManager {
262272
return [];
263273
}
264274

265-
const env = this.fsPathToEnv.get(scope.fsPath);
275+
const env = this.getFsPathToEnv(scope.fsPath);
266276
return env ? [env] : [];
267277
}
268278

@@ -279,7 +289,7 @@ export class VenvManager implements EnvironmentManager {
279289
return this.globalEnv;
280290
}
281291

282-
let env = this.fsPathToEnv.get(project.uri.fsPath);
292+
let env = this.getFsPathToEnv(project.uri.fsPath);
283293
if (!env) {
284294
env = this.findEnvironmentByPath(project.uri.fsPath);
285295
}
@@ -305,11 +315,11 @@ export class VenvManager implements EnvironmentManager {
305315
return;
306316
}
307317

308-
const before = this.fsPathToEnv.get(pw.uri.fsPath);
318+
const before = this.getFsPathToEnv(pw.uri.fsPath);
309319
if (environment) {
310-
this.fsPathToEnv.set(pw.uri.fsPath, environment);
320+
this.setFsPathToEnv(pw.uri.fsPath, environment);
311321
} else {
312-
this.fsPathToEnv.delete(pw.uri.fsPath);
322+
this.deleteFsPathToEnv(pw.uri.fsPath);
313323
}
314324
await setVenvForWorkspace(pw.uri.fsPath, environment?.environmentPath.fsPath);
315325

@@ -330,11 +340,11 @@ export class VenvManager implements EnvironmentManager {
330340

331341
const before: Map<string, PythonEnvironment | undefined> = new Map();
332342
projects.forEach((p) => {
333-
before.set(p.uri.fsPath, this.fsPathToEnv.get(p.uri.fsPath));
343+
before.set(p.uri.fsPath, this.getFsPathToEnv(p.uri.fsPath));
334344
if (environment) {
335-
this.fsPathToEnv.set(p.uri.fsPath, environment);
345+
this.setFsPathToEnv(p.uri.fsPath, environment);
336346
} else {
337-
this.fsPathToEnv.delete(p.uri.fsPath);
347+
this.deleteFsPathToEnv(p.uri.fsPath);
338348
}
339349
});
340350

@@ -464,10 +474,10 @@ export class VenvManager implements EnvironmentManager {
464474

465475
if (env) {
466476
const found = this.findEnvironmentByPath(env, sorted) ?? this.findEnvironmentByPath(env, globals);
467-
const previous = this.fsPathToEnv.get(p);
477+
const previous = this.getFsPathToEnv(p);
468478
const pw = this.api.getPythonProject(Uri.file(p));
469479
if (found) {
470-
this.fsPathToEnv.set(p, found);
480+
this.setFsPathToEnv(p, found);
471481
if (pw && previous?.envId.id !== found.envId.id) {
472482
events.push(() =>
473483
this._onDidChangeEnvironment.fire({ uri: pw.uri, old: undefined, new: found }),
@@ -483,7 +493,7 @@ export class VenvManager implements EnvironmentManager {
483493
);
484494
if (resolved) {
485495
// If resolved add it to the collection
486-
this.fsPathToEnv.set(p, resolved);
496+
this.setFsPathToEnv(p, resolved);
487497
this.addEnvironment(resolved, false);
488498
if (pw && previous?.envId.id !== resolved.envId.id) {
489499
events.push(() =>
@@ -497,7 +507,7 @@ export class VenvManager implements EnvironmentManager {
497507
} else {
498508
// There is NO selected venv, then try and choose the venv that is in the workspace.
499509
if (sorted.length === 1) {
500-
this.fsPathToEnv.set(p, sorted[0]);
510+
this.setFsPathToEnv(p, sorted[0]);
501511
} else {
502512
// These are sorted by version and by path length. The assumption is that the user would want to pick
503513
// latest version and the one that is closest to the workspace.
@@ -506,7 +516,7 @@ export class VenvManager implements EnvironmentManager {
506516
return t && path.normalize(t) === p;
507517
});
508518
if (found) {
509-
this.fsPathToEnv.set(p, found);
519+
this.setFsPathToEnv(p, found);
510520
}
511521
}
512522
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import assert from 'node:assert';
2+
import * as sinon from 'sinon';
3+
import { Uri } from 'vscode';
4+
import { getResourceUri, normalizePath } from '../../common/utils/pathUtils';
5+
import * as utils from '../../managers/common/utils';
6+
7+
suite('Path Utilities', () => {
8+
let isWindowsStub: sinon.SinonStub;
9+
10+
setup(() => {
11+
isWindowsStub = sinon.stub(utils, 'isWindows');
12+
});
13+
14+
teardown(() => {
15+
sinon.restore();
16+
});
17+
suite('getResourceUri', () => {
18+
const testRoot = process.cwd();
19+
20+
test('returns undefined when path is empty', () => {
21+
const result = getResourceUri('', testRoot);
22+
assert.strictEqual(result, undefined);
23+
});
24+
25+
test('returns undefined when path is undefined', () => {
26+
// @ts-ignore: Testing with undefined even though the type doesn't allow it
27+
const result = getResourceUri(undefined, testRoot);
28+
assert.strictEqual(result, undefined);
29+
});
30+
test('creates file URI from normal file path', () => {
31+
const testPath = '/path/to/file.txt';
32+
const result = getResourceUri(testPath, testRoot);
33+
34+
assert.ok(result instanceof Uri);
35+
assert.strictEqual(result?.scheme, 'file');
36+
assert.strictEqual(result?.path, testPath);
37+
});
38+
39+
test('creates file URI from Windows path', () => {
40+
const testPath = 'C:\\path\\to\\file.txt';
41+
const result = getResourceUri(testPath, testRoot);
42+
43+
assert.ok(result instanceof Uri);
44+
assert.strictEqual(result?.scheme, 'file');
45+
// path in the URI should use forward slashes
46+
assert.strictEqual(result?.path.includes('\\'), false);
47+
});
48+
49+
test('parses existing URI correctly', () => {
50+
const uriString = 'scheme://authority/path';
51+
const result = getResourceUri(uriString, testRoot);
52+
53+
assert.ok(result instanceof Uri);
54+
assert.strictEqual(result?.scheme, 'scheme');
55+
assert.strictEqual(result?.authority, 'authority');
56+
assert.strictEqual(result?.path, '/path');
57+
});
58+
59+
test('handles exception and returns undefined', () => {
60+
// Create a scenario that would cause an exception
61+
// For this test, we'll mock Uri.file to throw an error
62+
const originalUriFile = Uri.file;
63+
Uri.file = () => {
64+
throw new Error('Test error');
65+
};
66+
try {
67+
const result = getResourceUri('some-path', testRoot);
68+
assert.strictEqual(result, undefined);
69+
} finally {
70+
// Restore the original function
71+
Uri.file = originalUriFile;
72+
}
73+
});
74+
75+
test('handles relative paths by resolving against the provided root', () => {
76+
const path = require('path');
77+
78+
// Use a relative path
79+
const relativePath = './relative/path/file.txt';
80+
const customRoot = path.join(testRoot, 'custom/root');
81+
82+
const result = getResourceUri(relativePath, customRoot);
83+
84+
assert.ok(result instanceof Uri);
85+
assert.strictEqual(result?.scheme, 'file');
86+
// The resulting path should be resolved against the custom root
87+
assert.ok(
88+
result!.fsPath.replace(/\\/g, '/').toLowerCase().endsWith('relative/path/file.txt'),
89+
`Expected path to end with the relative path segment, but got: ${result!.fsPath}`,
90+
);
91+
92+
// Verify the path contains the custom root
93+
const normalizedResult = result!.fsPath.replace(/\\/g, '/').toLowerCase();
94+
const normalizedRoot = customRoot.replace(/\\/g, '/').toLowerCase();
95+
assert.ok(
96+
normalizedResult.includes(normalizedRoot),
97+
`Expected path to include the custom root "${normalizedRoot}", but got: ${normalizedResult}`,
98+
);
99+
});
100+
});
101+
102+
suite('normalizePath', () => {
103+
test('replaces backslashes with forward slashes', () => {
104+
const testPath = 'C:\\path\\to\\file.txt';
105+
const result = normalizePath(testPath);
106+
107+
assert.strictEqual(result.includes('\\'), false);
108+
assert.strictEqual(result, 'C:/path/to/file.txt');
109+
});
110+
111+
test('converts to lowercase on Windows', () => {
112+
isWindowsStub.returns(true);
113+
114+
const testPath = 'C:/Path/To/File.txt';
115+
const result = normalizePath(testPath);
116+
117+
assert.strictEqual(result, 'c:/path/to/file.txt');
118+
});
119+
120+
test('preserves case on non-Windows', () => {
121+
isWindowsStub.returns(false);
122+
123+
const testPath = 'C:/Path/To/File.txt';
124+
const result = normalizePath(testPath);
125+
126+
assert.strictEqual(result, 'C:/Path/To/File.txt');
127+
});
128+
});
129+
});

src/test/copilotTools.unit.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
PythonPackageGetterApi,
1111
PythonPackageManagementApi,
1212
PythonProjectEnvironmentApi,
13+
PythonProjectGetterApi,
1314
} from '../api';
1415
import { createDeferred } from '../common/utils/deferred';
1516
import {
@@ -239,16 +240,14 @@ suite('InstallPackageTool Tests', () => {
239240

240241
suite('GetEnvironmentInfoTool Tests', () => {
241242
let getEnvironmentInfoTool: GetEnvironmentInfoTool;
242-
let mockApi: typeMoq.IMock<PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonPackageManagementApi>;
243+
let mockApi: typeMoq.IMock<PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonProjectGetterApi>;
243244
let mockEnvironment: typeMoq.IMock<PythonEnvironment>;
244245
let em: typeMoq.IMock<EnvironmentManagers>;
245246
let managerSys: typeMoq.IMock<InternalEnvironmentManager>;
246247

247248
setup(() => {
248249
// Create mock functions
249-
mockApi = typeMoq.Mock.ofType<
250-
PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonPackageManagementApi
251-
>();
250+
mockApi = typeMoq.Mock.ofType<PythonProjectEnvironmentApi & PythonPackageGetterApi & PythonProjectGetterApi>();
252251
mockEnvironment = typeMoq.Mock.ofType<PythonEnvironment>();
253252

254253
// eslint-disable-next-line @typescript-eslint/no-explicit-any

0 commit comments

Comments
 (0)