Skip to content

Commit 55b8efd

Browse files
Fix missing dev-requirements.txt in workspace dependencies list (#910)
Fixes #506 Added an explicit glob pattern `*requirements*.txt` to ensure requirements files at the workspace root are reliably found. The fix includes: 1. **Additional search pattern**: Added `findFiles('*requirements*.txt')` to explicitly search the workspace root 2. **Deduplication logic**: Implemented deduplication using a Map keyed by file system path to handle overlapping patterns 3. **Comprehensive tests**: Created 5 new unit tests to validate the fix Now both patterns work together: - `**/*requirements*.txt` - finds requirements files in subdirectories - `*requirements*.txt` - finds requirements files at workspace root ## Requirements Files Now Discovered This fix ensures all common requirements file naming patterns are found: - `requirements.txt` - main dependencies - `dev-requirements.txt` - development dependencies ✨ - `test-requirements.txt` - testing dependencies ✨ - `docs-requirements.txt` - documentation dependencies ✨ - `prod-requirements.txt` - production dependencies ✨ - Any other `*requirements*.txt` variant ✨ <!-- START COPILOT CODING AGENT SUFFIX --> <details> <summary>Original prompt</summary> > > ---- > > *This section details on the original issue you should resolve* > > <issue_title>Missing requirements file in workspace dependencies list</issue_title> > <issue_description>I have the following file structure > > ![Image](https://github.com/user-attachments/assets/fccc626f-dedd-4c6e-8d66-93b7fc47ec6e) > > - click + on venv in environments panel > - click to create environment in root directory > - custom environment > - install workspace dependencies > - I only see `requirements.txt` as an option > > I expect I would see both dev-requirements.txt and requirements.txt because they are both present in the root folder > > > ![Image](https://github.com/user-attachments/assets/0868fe35-f0c1-4b38-bf50-e40d7835a4f2) > > </issue_description> > > ## Comments on the Issue (you are @copilot in this section) > > <comments> > <comment_new><author>@eleanorjboyd</author><body> > todo: adopt dev-requirements.txt as something we search in the python environment extension when finding dependency files.</body></comment_new> > </comments> > </details> <!-- START COPILOT CODING AGENT TIPS --> --- 💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com>
1 parent 07b822f commit 55b8efd

File tree

3 files changed

+211
-6
lines changed

3 files changed

+211
-6
lines changed

.github/instructions/testing-workflow.instructions.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -537,17 +537,16 @@ envConfig.inspect
537537
- ❌ Tests that don't clean up mocks properly
538538
- ❌ Overly complex test setup that's hard to understand
539539

540-
## 🧠 Agent Learning Patterns
541-
542-
### Key Implementation Insights
540+
## 🧠 Agent Learnings
543541

544542
- Always use dynamic path construction with Node.js `path` module when testing functions that resolve paths against workspace folders to ensure cross-platform compatibility (1)
545543
- Use `runTests` tool for programmatic test execution rather than terminal commands for better integration and result parsing (1)
546-
- Mock wrapper functions (e.g., `workspaceApis.getConfiguration()`) instead of VS Code APIs directly to avoid stubbing issues (1)
544+
- Mock wrapper functions (e.g., `workspaceApis.getConfiguration()`) instead of VS Code APIs directly to avoid stubbing issues (2)
547545
- Start compilation with `npm run watch-tests` before test execution to ensure TypeScript files are built (1)
548-
- Use `sinon.match()` patterns for resilient assertions that don't break on minor output changes (1)
546+
- Use `sinon.match()` patterns for resilient assertions that don't break on minor output changes (2)
549547
- Fix test issues iteratively - run tests, analyze failures, apply fixes, repeat until passing (1)
550548
- When fixing mock environment creation, use `null` to truly omit properties rather than `undefined` (1)
551549
- Always recompile TypeScript after making import/export changes before running tests, as stubs won't work if they're applied to old compiled JavaScript that doesn't have the updated imports (2)
552550
- Create proxy abstraction functions for Node.js APIs like `cp.spawn` to enable clean testing - use function overloads to preserve Node.js's intelligent typing while making the functions mockable (1)
553551
- When unit tests fail with VS Code API errors like `TypeError: X is not a constructor` or `Cannot read properties of undefined (reading 'Y')`, check if VS Code APIs are properly mocked in `/src/test/unittests.ts` - add missing Task-related APIs (`Task`, `TaskScope`, `ShellExecution`, `TaskRevealKind`, `TaskPanelKind`) and namespace mocks (`tasks`) following the existing pattern of `mockedVSCode.X = vscodeMocks.vscMockExtHostedTypes.X` (1)
552+
- Create minimal mock objects with only required methods and use TypeScript type assertions (e.g., mockApi as PythonEnvironmentApi) to satisfy interface requirements instead of implementing all interface methods when only specific methods are needed for the test (1)

src/managers/builtin/pipUtils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,13 +183,17 @@ export async function getProjectInstallable(
183183
const results: Uri[] = (
184184
await Promise.all([
185185
findFiles('**/*requirements*.txt', exclude, undefined, token),
186+
findFiles('*requirements*.txt', exclude, undefined, token),
186187
findFiles('**/requirements/*.txt', exclude, undefined, token),
187188
findFiles('**/pyproject.toml', exclude, undefined, token),
188189
])
189190
).flat();
190191

192+
// Deduplicate by fsPath
193+
const uniqueResults = Array.from(new Map(results.map((uri) => [uri.fsPath, uri])).values());
194+
191195
const fsPaths = projects.map((p) => p.uri.fsPath);
192-
const filtered = results
196+
const filtered = uniqueResults
193197
.filter((uri) => {
194198
const p = api.getPythonProject(uri)?.uri.fsPath;
195199
return p && fsPaths.includes(p);
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import assert from 'assert';
2+
import * as path from 'path';
3+
import * as sinon from 'sinon';
4+
import { CancellationToken, Progress, ProgressOptions, Uri } from 'vscode';
5+
import { PythonEnvironmentApi, PythonProject } from '../../../api';
6+
import * as winapi from '../../../common/window.apis';
7+
import * as wapi from '../../../common/workspace.apis';
8+
import { getProjectInstallable } from '../../../managers/builtin/pipUtils';
9+
10+
suite('Pip Utils - getProjectInstallable', () => {
11+
let findFilesStub: sinon.SinonStub;
12+
let withProgressStub: sinon.SinonStub;
13+
// Minimal mock that only implements the methods we need for this test
14+
// Using type assertion to satisfy TypeScript since we only need getPythonProject
15+
let mockApi: { getPythonProject: (uri: Uri) => PythonProject | undefined };
16+
17+
setup(() => {
18+
findFilesStub = sinon.stub(wapi, 'findFiles');
19+
// Stub withProgress to immediately execute the callback
20+
withProgressStub = sinon.stub(winapi, 'withProgress');
21+
withProgressStub.callsFake(
22+
async (
23+
_options: ProgressOptions,
24+
callback: (
25+
progress: Progress<{ message?: string; increment?: number }>,
26+
token: CancellationToken,
27+
) => Thenable<unknown>,
28+
) => {
29+
return await callback(
30+
{} as Progress<{ message?: string; increment?: number }>,
31+
{ isCancellationRequested: false } as CancellationToken,
32+
);
33+
},
34+
);
35+
36+
const workspacePath = Uri.file('/test/path/root').fsPath;
37+
mockApi = {
38+
getPythonProject: (uri: Uri) => {
39+
// Return a project for any URI in workspace
40+
if (uri.fsPath.startsWith(workspacePath)) {
41+
return { name: 'workspace', uri: Uri.file(workspacePath) };
42+
}
43+
return undefined;
44+
},
45+
};
46+
});
47+
48+
teardown(() => {
49+
sinon.restore();
50+
});
51+
52+
test('should find dev-requirements.txt at workspace root', async () => {
53+
// Arrange: Mock findFiles to return both requirements.txt and dev-requirements.txt
54+
findFilesStub.callsFake((pattern: string) => {
55+
if (pattern === '**/*requirements*.txt') {
56+
// This pattern might not match root-level files in VS Code
57+
return Promise.resolve([]);
58+
} else if (pattern === '*requirements*.txt') {
59+
// This pattern should match root-level files
60+
const workspacePath = Uri.file('/test/path/root').fsPath;
61+
return Promise.resolve([
62+
Uri.file(path.join(workspacePath, 'requirements.txt')),
63+
Uri.file(path.join(workspacePath, 'dev-requirements.txt')),
64+
Uri.file(path.join(workspacePath, 'test-requirements.txt')),
65+
]);
66+
} else if (pattern === '**/requirements/*.txt') {
67+
return Promise.resolve([]);
68+
} else if (pattern === '**/pyproject.toml') {
69+
return Promise.resolve([]);
70+
}
71+
return Promise.resolve([]);
72+
});
73+
74+
// Act: Call getProjectInstallable
75+
const workspacePath = Uri.file('/test/path/root').fsPath;
76+
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
77+
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
78+
79+
// Assert: Should find all three requirements files
80+
assert.strictEqual(result.length, 3, 'Should find three requirements files');
81+
82+
const names = result.map((r) => r.name).sort();
83+
assert.deepStrictEqual(
84+
names,
85+
['dev-requirements.txt', 'requirements.txt', 'test-requirements.txt'],
86+
'Should find requirements.txt, dev-requirements.txt, and test-requirements.txt',
87+
);
88+
89+
// Verify each file has correct properties
90+
result.forEach((item) => {
91+
assert.strictEqual(item.group, 'Requirements', 'Should be in Requirements group');
92+
assert.ok(item.args, 'Should have args');
93+
assert.strictEqual(item.args?.length, 2, 'Should have 2 args');
94+
assert.strictEqual(item.args?.[0], '-r', 'First arg should be -r');
95+
assert.ok(item.uri, 'Should have a URI');
96+
});
97+
});
98+
99+
test('should deduplicate files found by multiple patterns', async () => {
100+
// Arrange: Mock both patterns to return the same file
101+
findFilesStub.callsFake((pattern: string) => {
102+
if (pattern === '**/*requirements*.txt') {
103+
const workspacePath = Uri.file('/test/path/root').fsPath;
104+
return Promise.resolve([Uri.file(path.join(workspacePath, 'dev-requirements.txt'))]);
105+
} else if (pattern === '*requirements*.txt') {
106+
const workspacePath = Uri.file('/test/path/root').fsPath;
107+
return Promise.resolve([
108+
Uri.file(path.join(workspacePath, 'dev-requirements.txt')),
109+
Uri.file(path.join(workspacePath, 'requirements.txt')),
110+
]);
111+
} else if (pattern === '**/requirements/*.txt') {
112+
return Promise.resolve([]);
113+
} else if (pattern === '**/pyproject.toml') {
114+
return Promise.resolve([]);
115+
}
116+
return Promise.resolve([]);
117+
});
118+
119+
// Act: Call getProjectInstallable
120+
const workspacePath = Uri.file('/test/path/root').fsPath;
121+
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
122+
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
123+
124+
// Assert: Should deduplicate and only have 2 unique files
125+
assert.strictEqual(result.length, 2, 'Should deduplicate and have 2 unique files');
126+
127+
const names = result.map((r) => r.name).sort();
128+
assert.deepStrictEqual(names, ['dev-requirements.txt', 'requirements.txt'], 'Should have deduplicated results');
129+
});
130+
131+
test('should find requirements files in subdirectories', async () => {
132+
// Arrange: Mock findFiles to return files in subdirectories
133+
findFilesStub.callsFake((pattern: string) => {
134+
if (pattern === '**/*requirements*.txt') {
135+
const workspacePath = Uri.file('/test/path/root').fsPath;
136+
return Promise.resolve([Uri.file(path.join(workspacePath, 'subdir', 'dev-requirements.txt'))]);
137+
} else if (pattern === '*requirements*.txt') {
138+
const workspacePath = Uri.file('/test/path/root').fsPath;
139+
return Promise.resolve([Uri.file(path.join(workspacePath, 'requirements.txt'))]);
140+
} else if (pattern === '**/requirements/*.txt') {
141+
const workspacePath = Uri.file('/test/path/root').fsPath;
142+
return Promise.resolve([Uri.file(path.join(workspacePath, 'requirements', 'test.txt'))]);
143+
} else if (pattern === '**/pyproject.toml') {
144+
return Promise.resolve([]);
145+
}
146+
return Promise.resolve([]);
147+
});
148+
149+
// Act: Call getProjectInstallable
150+
const workspacePath = Uri.file('/test/path/root').fsPath;
151+
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
152+
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
153+
154+
// Assert: Should find all files
155+
assert.strictEqual(result.length, 3, 'Should find three files');
156+
157+
const names = result.map((r) => r.name).sort();
158+
assert.deepStrictEqual(
159+
names,
160+
['dev-requirements.txt', 'requirements.txt', 'test.txt'],
161+
'Should find files at different levels',
162+
);
163+
});
164+
165+
test('should return empty array when no projects provided', async () => {
166+
// Act: Call with no projects
167+
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, undefined);
168+
169+
// Assert: Should return empty array
170+
assert.strictEqual(result.length, 0, 'Should return empty array');
171+
assert.ok(!findFilesStub.called, 'Should not call findFiles when no projects');
172+
});
173+
174+
test('should filter out files not in project directories', async () => {
175+
// Arrange: Mock findFiles to return files from multiple directories
176+
findFilesStub.callsFake((pattern: string) => {
177+
if (pattern === '*requirements*.txt') {
178+
const workspacePath = Uri.file('/test/path/root').fsPath;
179+
const otherPath = Uri.file('/other-dir').fsPath;
180+
return Promise.resolve([
181+
Uri.file(path.join(workspacePath, 'requirements.txt')),
182+
Uri.file(path.join(otherPath, 'requirements.txt')), // Should be filtered out
183+
]);
184+
} else {
185+
return Promise.resolve([]);
186+
}
187+
});
188+
189+
// Act: Call with only workspace project
190+
const workspacePath = Uri.file('/test/path/root').fsPath;
191+
const projects = [{ name: 'workspace', uri: Uri.file(workspacePath) }];
192+
const result = await getProjectInstallable(mockApi as PythonEnvironmentApi, projects);
193+
194+
// Assert: Should only include files from workspace
195+
assert.strictEqual(result.length, 1, 'Should only include files from project directory');
196+
const firstResult = result[0];
197+
assert.ok(firstResult, 'Should have at least one result');
198+
assert.strictEqual(firstResult.name, 'requirements.txt');
199+
assert.ok(firstResult.uri, 'Should have a URI');
200+
assert.ok(firstResult.uri.fsPath.startsWith(workspacePath), 'Should be in workspace directory');
201+
});
202+
});

0 commit comments

Comments
 (0)