Skip to content
Open
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
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -674,10 +674,15 @@
"type": "boolean"
},
"python.testing.autoTestDiscoverOnSavePattern": {
"default": "**/*.py",
"description": "%python.testing.autoTestDiscoverOnSavePattern.description%",
"additionalProperties": {
"type": "boolean"
},
"default": {
"**/*.py": true
},
"markdownDescription": "%python.testing.autoTestDiscoverOnSavePattern.description%",
"scope": "resource",
"type": "string"
"type": "object"
},
"python.testing.cwd": {
"default": null,
Expand Down
2 changes: 1 addition & 1 deletion package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"python.terminal.focusAfterLaunch.description": "When launching a python terminal, whether to focus the cursor on the terminal.",
"python.terminal.launchArgs.description": "Python launch arguments to use when executing a file in the terminal.",
"python.testing.autoTestDiscoverOnSaveEnabled.description": "Enable auto run test discovery when saving a test file.",
"python.testing.autoTestDiscoverOnSavePattern.description": "Glob pattern used to determine which files are used by autoTestDiscoverOnSaveEnabled.",
"python.testing.autoTestDiscoverOnSavePattern.description": "Glob patterns that decide which saved files trigger automatic test discovery when `#python.testing.autoTestDiscoverOnSaveEnabled#` is enabled. Each key is a glob and the value turns that pattern on (`true`) or off (`false`). Patterns merge across default, user, workspace, and folder settings the same way `#files.exclude#` and `#search.exclude#` do, so a pattern in user settings and another in workspace settings both apply. Set a pattern to `false` to turn off that pattern from a broader scope, including the default `**/*.py`. A saved file triggers discovery when it matches any enabled pattern. Patterns are matched against the workspace-relative path and the absolute path. A single glob string is still accepted so existing settings keep working, but a string replaces the whole value and does not merge across scopes.",
"python.testing.cwd.description": "Optional working directory for tests.",
"python.testing.debugPort.description": "Port number used for debugging of tests.",
"python.testing.promptToConfigure.description": "Prompt to configure a test framework if potential tests directories are discovered.",
Expand Down
17 changes: 15 additions & 2 deletions src/client/common/configSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { IInterpreterAutoSelectionProxyService } from '../interpreter/autoSelect
import { sendTelemetryEvent } from '../telemetry';
import { EventName } from '../telemetry/constants';
import { sendSettingTelemetry } from '../telemetry/envFileTelemetry';
import {
DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN,
resolveAutoTestDiscoverOnSavePattern,
} from '../testing/configuration/autoTestDiscoverOnSavePattern';
import { ITestingSettings } from '../testing/configuration/types';
import { IWorkspaceService } from './application/types';
import { WorkspaceService } from './application/workspace';
Expand Down Expand Up @@ -331,7 +335,7 @@ export class PythonSettings implements IPythonSettings {
unittestEnabled: false,
pytestPath: 'pytest',
autoTestDiscoverOnSaveEnabled: true,
autoTestDiscoverOnSavePattern: '**/*.py',
autoTestDiscoverOnSavePattern: { ...DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN },
} as ITestingSettings;
}
}
Expand All @@ -348,7 +352,7 @@ export class PythonSettings implements IPythonSettings {
unittestArgs: [],
unittestEnabled: false,
autoTestDiscoverOnSaveEnabled: true,
autoTestDiscoverOnSavePattern: '**/*.py',
autoTestDiscoverOnSavePattern: { ...DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN },
};
this.testing.pytestPath = getAbsolutePath(systemVariables.resolveAny(this.testing.pytestPath), workspaceRoot);
if (this.testing.cwd) {
Expand All @@ -359,6 +363,15 @@ export class PythonSettings implements IPythonSettings {
this.testing.pytestArgs = this.testing.pytestArgs.map((arg) => systemVariables.resolveAny(arg));
this.testing.unittestArgs = this.testing.unittestArgs.map((arg) => systemVariables.resolveAny(arg));

// Glob patterns are the object keys. resolveAny substitutes variables in values, so resolve the keys here.
const resolvedPattern = resolveAutoTestDiscoverOnSavePattern(
this.testing.autoTestDiscoverOnSavePattern,
(value) => systemVariables.resolveAny(value),
);
this.testing.autoTestDiscoverOnSavePattern = resolvedPattern ?? {
...DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN,
};

const terminalSettings = systemVariables.resolveAny(pythonSettings.get<ITerminalSettings>('terminal'))!;
if (this.terminal) {
Object.assign<ITerminalSettings, ITerminalSettings>(this.terminal, terminalSettings);
Expand Down
143 changes: 143 additions & 0 deletions src/client/testing/configuration/autoTestDiscoverOnSavePattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

import * as minimatch from 'minimatch';
import { AutoTestDiscoverOnSavePattern } from './types';

/**
* Default pattern registered for `python.testing.autoTestDiscoverOnSavePattern`.
* Kept in sync with the `default` in package.json.
*/
export const DEFAULT_AUTO_TEST_DISCOVER_ON_SAVE_PATTERN: Readonly<Record<string, boolean>> = {
'**/*.py': true,
};

/**
* Resolve `${...}` variables in glob keys.
*
* `SystemVariables.resolveAny` walks object values and leaves keys untouched.
* Patterns live in the keys, the same way `files.exclude` does, so callers
* pass the string resolver and we rebuild the map.
* A legacy string pattern is returned unchanged; the caller has already
* resolved that string.
*/
export function resolveAutoTestDiscoverOnSavePattern(
pattern: AutoTestDiscoverOnSavePattern | undefined,
resolveString: (value: string) => string,
): AutoTestDiscoverOnSavePattern | undefined {
if (typeof pattern === 'string' || !isPatternMap(pattern)) {
return pattern;
}

const resolved: Record<string, boolean> = {};
Object.entries(pattern).forEach(([key, value]) => {
if (typeof value !== 'boolean') {
return;
}
const resolvedKey = resolveString(key).trim();
if (!resolvedKey) {
return;
}
// A later entry wins when two keys resolve to the same glob, which
// matches the last-write behavior of a settings object.
resolved[resolvedKey] = value;
});
return resolved;
}

/**
* A saved file triggers discovery when it matches any pattern whose value is `true`.
*
* VS Code deep-merges this object across default, user, workspace, and folder
* scopes before the extension reads it. A `false` value turns that one pattern
* off so a narrower scope can disable a pattern inherited from a broader scope
* (including the default Python glob). It does not exclude a file that matches a
* different enabled pattern. That is the same model as `files.exclude`.
*
* The previous setting was a single glob string matched against the absolute
* path. Object patterns are also matched against the workspace-relative path
* so globs written like `files.exclude` (relative to the workspace) work.
*/
export function matchesAutoTestDiscoverOnSavePattern(
fsPath: string,
pattern: AutoTestDiscoverOnSavePattern | undefined | null,
workspaceRoot?: string,
): boolean {
const globs = enabledGlobs(pattern);
if (globs.length === 0) {
return false;
}
const candidates = pathsToMatch(fsPath, workspaceRoot);
return globs.some((glob) => {
const normalizedGlob = toPosixPath(glob).trim();
return (
normalizedGlob.length > 0 && candidates.some((candidate) => minimatch.default(candidate, normalizedGlob))
);
});
}

function enabledGlobs(pattern: AutoTestDiscoverOnSavePattern | undefined | null): string[] {
if (typeof pattern === 'string') {
const glob = pattern.trim();
return glob ? [glob] : [];
}
if (!isPatternMap(pattern)) {
return [];
}
return Object.keys(pattern).filter((key) => pattern[key] === true && key.trim().length > 0);
}

function pathsToMatch(fsPath: string, workspaceRoot?: string): string[] {
const absolutePath = toPosixPath(fsPath);
const candidates = new Set<string>([absolutePath]);

// minimatch treats `:` as an extglob character, so `C:/...` often fails to
// match a `**` glob. Also try the path without the drive prefix.
const withoutDrive = absolutePath.replace(/^[A-Za-z]:/, '');
if (withoutDrive !== absolutePath) {
candidates.add(withoutDrive);
}

if (workspaceRoot) {
const relativePath = workspaceRelativePath(absolutePath, workspaceRoot);
if (relativePath) {
candidates.add(relativePath);
}
}

return Array.from(candidates);
}

function workspaceRelativePath(fsPath: string, workspaceRoot: string): string | undefined {
let root = toPosixPath(workspaceRoot);
if (root.endsWith('/')) {
root = root.slice(0, -1);
}
if (!root) {
return undefined;
}

// Windows paths are case-insensitive. Treat a drive-letter path the same
// way when tests run on a case-sensitive OS.
const windows = process.platform === 'win32' || /^[A-Za-z]:\//.test(fsPath);
const fileForCompare = windows ? fsPath.toLowerCase() : fsPath;
const rootForCompare = windows ? root.toLowerCase() : root;
const prefix = `${rootForCompare}/`;
if (!fileForCompare.startsWith(prefix)) {
return undefined;
}
const relativePath = fsPath.slice(prefix.length);
return relativePath.length > 0 ? relativePath : undefined;
}

/**
* minimatch treats `\` as an escape. Normalize separators so a Windows path
* and a glob written with backslashes match the same way they do elsewhere.
*/
function toPosixPath(value: string): string {
return value.replace(/\\/g, '/');
}

function isPatternMap(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
9 changes: 8 additions & 1 deletion src/client/testing/configuration/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

/**
* Glob used to decide which saved files trigger test discovery.
* A string is the legacy single-pattern form. An object maps each glob to a
* boolean so VS Code can merge entries across settings scopes.
*/
export type AutoTestDiscoverOnSavePattern = string | { readonly [glob: string]: boolean };

export interface ITestingSettings {
readonly promptToConfigure: boolean;
readonly debugPort: number;
Expand All @@ -11,7 +18,7 @@ export interface ITestingSettings {
unittestArgs: string[];
cwd?: string;
readonly autoTestDiscoverOnSaveEnabled: boolean;
readonly autoTestDiscoverOnSavePattern: string;
autoTestDiscoverOnSavePattern: AutoTestDiscoverOnSavePattern;
}

export type TestSettingsPropertyNames = {
Expand Down
9 changes: 7 additions & 2 deletions src/client/testing/testController/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import { inject, injectable, named } from 'inversify';
import { uniq } from 'lodash';
import * as minimatch from 'minimatch';
import {
CancellationToken,
TestController,
Expand Down Expand Up @@ -35,6 +34,7 @@ import { sendTelemetryEvent } from '../../telemetry';
import { EventName } from '../../telemetry/constants';
import type { UnitTestRunFailureCategory } from '../../telemetry/constants';
import { PYTEST_PROVIDER, UNITTEST_PROVIDER } from '../common/constants';
import { matchesAutoTestDiscoverOnSavePattern } from '../configuration/autoTestDiscoverOnSavePattern';
import { TestProvider } from '../types';
import { createErrorTestItem, DebugTestTag, getNodeByUri, RunTestTag } from './common/testItemUtilities';
import { buildErrorNodeOptions } from './common/utils';
Expand Down Expand Up @@ -1126,9 +1126,14 @@ export class PythonTestController implements ITestController, IExtensionSingleAc
this.disposables.push(
onDidSaveTextDocument(async (doc: TextDocument) => {
const settings = this.configSettings.getSettings(doc.uri);
const workspaceFolder = this.workspaceService.getWorkspaceFolder(doc.uri);
if (
settings.testing.autoTestDiscoverOnSaveEnabled &&
minimatch.default(doc.uri.fsPath, settings.testing.autoTestDiscoverOnSavePattern)
matchesAutoTestDiscoverOnSavePattern(
doc.uri.fsPath,
settings.testing.autoTestDiscoverOnSavePattern,
workspaceFolder?.uri.fsPath,
)
) {
traceVerbose(`Testing: Trigger refresh after saving ${doc.uri.fsPath}`);
this.sendTriggerTelemetry('watching');
Expand Down
45 changes: 45 additions & 0 deletions src/test/common/configSettings/configSettings.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,4 +308,49 @@ suite('Python Settings', async () => {
test('Experiments (not enabled)', () => testExperiments(false));

test('Experiments (enabled)', () => testExperiments(true));

test('autoTestDiscoverOnSavePattern resolves glob keys and preserves a legacy string', () => {
const previous = process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT;
process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT = '/repo/tests';
try {
expected.pythonPath = 'python3';
expected.testing = {
promptToConfigure: true,
debugPort: 3000,
pytestEnabled: false,
pytestPath: 'pytest',
pytestArgs: [],
unittestEnabled: false,
unittestArgs: [],
autoTestDiscoverOnSaveEnabled: true,
autoTestDiscoverOnSavePattern: {
'${env:AUTO_TEST_DISCOVER_PATTERN_ROOT}/**/test_*.py': true,
'**/*.py': false,
},
};
initializeConfig(expected);

settings.update(config.object);

expect(settings.testing.autoTestDiscoverOnSavePattern).to.deep.equal({
'/repo/tests/**/test_*.py': true,
'**/*.py': false,
});

expected.testing = {
...expected.testing,
autoTestDiscoverOnSavePattern: '${env:AUTO_TEST_DISCOVER_PATTERN_ROOT}/**/test_*.py',
};
initializeConfig(expected);
settings.update(config.object);

expect(settings.testing.autoTestDiscoverOnSavePattern).to.equal('/repo/tests/**/test_*.py');
} finally {
if (previous === undefined) {
delete process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT;
} else {
process.env.AUTO_TEST_DISCOVER_PATTERN_ROOT = previous;
}
}
});
});
Loading