Skip to content

Commit 1563453

Browse files
committed
feat: glob pattern support in executionEnvironments root and extraPaths
1 parent ba43b2d commit 1563453

8 files changed

Lines changed: 209 additions & 32 deletions

File tree

docs/configuration/config-files.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ The following settings control the *environment* in which basedpyright will chec
3030

3131
- **verboseOutput** [boolean]: Specifies whether output logs should be verbose. This is useful when diagnosing certain problems like import resolution issues.
3232

33-
- **extraPaths** [array of strings, optional]: Additional search paths that will be used when searching for modules imported by files.
33+
- **extraPaths** [array of strings, optional]: Additional search paths that will be used when searching for modules imported by files. Glob patterns (`**`, `*`, `?`) are supported *(basedpyright exclusive)* — matching directories are expanded at configuration time.
3434

3535
- **pythonVersion** [string, optional]: Specifies the version of Python that will be used to execute the source code. The version should be specified as a string in the format "M.m" where M is the major version and m is the minor (e.g. `"3.0"` or `"3.6"`). If a version is provided, pyright will generate errors if the source code makes use of language features that are not supported in that version. It will also tailor its use of type stub files, which conditionalizes type definitions based on the version. If no version is specified, pyright will use the version of the current python interpreter, if one is present.
3636

@@ -314,13 +314,13 @@ The following settings allow more fine grained control over the **typeCheckingMo
314314

315315

316316
## Execution Environment Options
317-
Pyright allows multiple execution environments to be defined for different portions of your source tree. For example, a subtree may be designed to run with different import search paths or a different version of the python interpreter than the rest of the source base.
317+
Pyright allows multiple "execution environments" to be defined for different portions of your source tree. For example, a subtree may be designed to run with different import search paths or a different version of the python interpreter than the rest of the source base.
318318

319-
The following settings can be specified for each execution environment. Each source file within a project is associated with at most one execution environment -- the first one whose root directory contains that file.
319+
The following settings can be specified for each execution environment. Each source file within a project is associated with at most one execution environment -- the first one whose root matches that file. Environments are searched in array order; the first match wins.
320320

321-
- **root** [string, required]: Root path for the code that will execute within this execution environment.
321+
- **root** [string, required]: Root path for the code that will execute within this execution environment. Glob patterns (`**`, `*`, `?`) are supported *(basedpyright exclusive)* — when used, import resolution falls back to the project root.
322322

323-
- **extraPaths** [array of strings, optional]: Additional search paths (in addition to the root path) that will be used when searching for modules imported by files within this execution environment. If specified, this overrides the default extraPaths setting when resolving imports for files within this execution environment. Note that each files execution environment mapping is independent, so if file A is in one execution environment and imports a second file B within a second execution environment, any imports from B will use the extraPaths in the second execution environment.
323+
- **extraPaths** [array of strings, optional]: Additional search paths (in addition to the root path) that will be used when searching for modules imported by files within this execution environment. Glob patterns (`**`, `*`, `?`) are supported *(basedpyright exclusive)* — matching directories are expanded at configuration time. If specified, this overrides the default extraPaths setting when resolving imports for files within this execution environment. Note that each file's execution environment mapping is independent, so if file A is in one execution environment and imports a second file B within a second execution environment, any imports from B will use the extraPaths in the second execution environment.
324324

325325
- **pythonVersion** [string, optional]: The version of Python used for this execution environment. If not specified, the global `pythonVersion` setting is used instead.
326326

@@ -377,10 +377,10 @@ The following is an example of a pyright config file:
377377
]
378378
},
379379
{
380-
"root": "src/tests",
380+
"root": "**/tests",
381381
"reportPrivateUsage": false,
382382
"extraPaths": [
383-
"src/tests/e2e",
383+
"**/fixtures",
384384
"src/sdk"
385385
]
386386
},
@@ -413,7 +413,7 @@ pythonPlatform = "Linux"
413413
executionEnvironments = [
414414
{ root = "src/web", pythonVersion = "3.5", pythonPlatform = "Windows", extraPaths = [ "src/service_libs" ], reportMissingImports = "warning" },
415415
{ root = "src/sdk", pythonVersion = "3.0", extraPaths = [ "src/backend" ] },
416-
{ root = "src/tests", reportPrivateUsage = false, extraPaths = ["src/tests/e2e", "src/sdk" ]},
416+
{ root = "**/tests", reportPrivateUsage = false, extraPaths = ["**/fixtures", "src/sdk"] },
417417
{ root = "src" }
418418
]
419419
```

packages/pyright-internal/src/analyzer/backgroundAnalysisProgram.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,8 @@ export class BackgroundAnalysisProgram {
277277
}
278278

279279
private _ensurePartialStubPackages(execEnv: ExecutionEnvironment) {
280-
this._backgroundAnalysis?.ensurePartialStubPackages(execEnv.root?.toString());
280+
const execEnvIndex = this.configOptions.getExecutionEnvironments().indexOf(execEnv);
281+
this._backgroundAnalysis?.ensurePartialStubPackages(execEnvIndex);
281282
return this._importResolver.ensurePartialStubPackages(execEnv);
282283
}
283284

packages/pyright-internal/src/analyzer/importResolver.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ export class ImportResolver {
413413
return false;
414414
}
415415

416-
if (this.partialStubs.isPartialStubPackagesScanned(execEnv)) {
416+
if (execEnv.root && this.partialStubs.isPathScanned(execEnv.root)) {
417417
return false;
418418
}
419419

packages/pyright-internal/src/backgroundAnalysisBase.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export interface IBackgroundAnalysis extends Disposable {
5555
setConfigOptions(configOptions: ConfigOptions): void;
5656
setTrackedFiles(fileUris: Uri[]): void;
5757
setAllowedThirdPartyImports(importNames: string[]): void;
58-
ensurePartialStubPackages(executionRoot: string | undefined): void;
58+
ensurePartialStubPackages(execEnvIndex: number): void;
5959
setFileOpened(fileUri: Uri, version: number | null, contents: string, options: OpenFileOptions): void;
6060
updateChainedUri(fileUri: Uri, chainedUri: Uri | undefined): void;
6161
setFileClosed(fileUri: Uri, isTracked?: boolean): void;
@@ -158,8 +158,8 @@ export class BackgroundAnalysisBase implements IBackgroundAnalysis {
158158
this.enqueueRequest({ requestType: 'setAllowedThirdPartyImports', data: serialize(importNames) });
159159
}
160160

161-
ensurePartialStubPackages(executionRoot: string | undefined) {
162-
this.enqueueRequest({ requestType: 'ensurePartialStubPackages', data: serialize({ executionRoot }) });
161+
ensurePartialStubPackages(execEnvIndex: number) {
162+
this.enqueueRequest({ requestType: 'ensurePartialStubPackages', data: serialize({ execEnvIndex }) });
163163
}
164164

165165
setFileOpened(fileUri: Uri, version: number | null, contents: string, options: OpenFileOptions) {
@@ -589,8 +589,8 @@ export abstract class BackgroundAnalysisRunnerBase extends BackgroundThreadBase
589589
}
590590

591591
case 'ensurePartialStubPackages': {
592-
const { executionRoot } = deserialize(msg.data);
593-
this.handleEnsurePartialStubPackages(executionRoot);
592+
const { execEnvIndex } = deserialize(msg.data);
593+
this.handleEnsurePartialStubPackages(execEnvIndex);
594594
break;
595595
}
596596

@@ -775,10 +775,8 @@ export abstract class BackgroundAnalysisRunnerBase extends BackgroundThreadBase
775775
this.program.setAllowedThirdPartyImports(importNames);
776776
}
777777

778-
protected handleEnsurePartialStubPackages(executionRoot: string | undefined) {
779-
const execEnv = this._configOptions
780-
.getExecutionEnvironments()
781-
.find((e) => e.root?.toString() === executionRoot);
778+
protected handleEnsurePartialStubPackages(execEnvIndex: number) {
779+
const execEnv = this._configOptions.getExecutionEnvironments()[execEnvIndex];
782780
if (execEnv) {
783781
this.importResolver.ensurePartialStubPackages(execEnv);
784782
}

packages/pyright-internal/src/common/configOptions.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
* Class that holds the configuration options for the analyzer.
88
*/
99

10+
import { globSync } from 'node:fs';
11+
import { matchesGlob } from 'node:path/posix';
12+
1013
import { ImportLogger } from '../analyzer/importLogger';
1114
import { getPathsFromPthFiles } from '../analyzer/pythonPathUtils';
1215
import * as pathConsts from '../common/pathConsts';
@@ -27,7 +30,7 @@ import { PythonVersion, latestStablePythonVersion } from './pythonVersion';
2730
import { ServiceKeys } from './serviceKeys';
2831
import { ServiceProvider } from './serviceProvider';
2932
import { Uri } from './uri/uri';
30-
import { FileSpec, getFileSpec, isDirectory } from './uri/uriUtils';
33+
import { FileSpec, getFileSpec, getWildcardRoot, isDirectory } from './uri/uriUtils';
3134
import { userFacingOptionsList } from './stringUtils';
3235

3336
// prevent upstream changes from sneaking in and adding errors using console.error,
@@ -66,6 +69,8 @@ export class ExecutionEnvironment {
6669
// tools or playgrounds).
6770
skipNativeLibraries: boolean;
6871

72+
rootGlob?: string;
73+
6974
// Default to "." which indicates every file in the project.
7075
constructor(
7176
name: string,
@@ -1526,13 +1531,21 @@ export class ConfigOptions {
15261531
// execution environment is used.
15271532
findExecEnvironment(file: Uri): ExecutionEnvironment {
15281533
return (
1529-
this.executionEnvironments.find((env) => {
1530-
const envRoot = Uri.is(env.root) ? env.root : this.projectRoot.resolvePaths(env.root || '');
1531-
return file.startsWith(envRoot);
1532-
}) ?? this.getDefaultExecEnvironment()
1534+
this.executionEnvironments.find((env) => this._fileMatchesEnvironment(file, env)) ??
1535+
this.getDefaultExecEnvironment()
15331536
);
15341537
}
15351538

1539+
private _fileMatchesEnvironment(file: Uri, env: ExecutionEnvironment): boolean {
1540+
if (env.rootGlob !== undefined) {
1541+
const relative = this.projectRoot.getRelativePath(file)?.slice(2);
1542+
if (relative === undefined) return false;
1543+
return matchesGlob(relative, env.rootGlob + '/**');
1544+
}
1545+
const envRoot = Uri.is(env.root) ? env.root : this.projectRoot.resolvePaths(env.root || '');
1546+
return file.startsWith(envRoot);
1547+
}
1548+
15361549
getExecutionEnvironments(): ExecutionEnvironment[] {
15371550
if (this.executionEnvironments.length > 0) {
15381551
return this.executionEnvironments;
@@ -1691,7 +1704,7 @@ export class ConfigOptions {
16911704
if (typeof path !== 'string') {
16921705
console.error(`Config "extraPaths" field ${pathIndex} must be a string.`);
16931706
} else {
1694-
configExtraPaths!.push(configDirUri.resolvePaths(path));
1707+
this._resolveExtraPath(path, configDirUri, configExtraPaths, console);
16951708
}
16961709
});
16971710
this.defaultExtraPaths = [...configExtraPaths];
@@ -1989,6 +2002,20 @@ export class ConfigOptions {
19892002
return this.pythonEnvironmentName || this.pythonPath?.toString() || 'python';
19902003
}
19912004

2005+
private _resolveExtraPath(path: string, configDirUri: Uri, out: Uri[], console: ConsoleInterface) {
2006+
if (/[*?]/.test(path)) {
2007+
try {
2008+
for (const match of globSync(path, { cwd: configDirUri.getFilePath() })) {
2009+
out.push(configDirUri.resolvePaths(match));
2010+
}
2011+
} catch (e) {
2012+
console.error(`Failed to expand glob pattern "${path}": ${e}`);
2013+
}
2014+
} else {
2015+
out.push(configDirUri.resolvePaths(path));
2016+
}
2017+
}
2018+
19922019
private _convertBoolean(value: any, fieldName: string, defaultValue: boolean): boolean {
19932020
if (value === undefined) {
19942021
return defaultValue;
@@ -2043,7 +2070,8 @@ export class ConfigOptions {
20432070

20442071
// Validate the root.
20452072
if (envObj.root && typeof envObj.root === 'string') {
2046-
newExecEnv.root = configDirUri.resolvePaths(envObj.root);
2073+
newExecEnv.rootGlob = envObj.root;
2074+
newExecEnv.root = getWildcardRoot(configDirUri, envObj.root);
20472075
} else {
20482076
console.error(`Config executionEnvironments index ${index}: missing root value.`);
20492077
}
@@ -2067,7 +2095,7 @@ export class ConfigOptions {
20672095
` extraPaths field ${pathIndex} must be a string.`
20682096
);
20692097
} else {
2070-
newExecEnv.extraPaths.push(configDirUri.resolvePaths(path));
2098+
this._resolveExtraPath(path, configDirUri, newExecEnv.extraPaths, console);
20712099
}
20722100
});
20732101
}

packages/pyright-internal/src/tests/config.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import assert from 'assert';
1212
import { AnalyzerService } from '../analyzer/service';
1313
import { deserialize, serialize } from '../backgroundThreadBase';
1414
import { CommandLineOptions, DiagnosticSeverityOverrides } from '../common/commandLineOptions';
15-
import { ConfigOptions, ExecutionEnvironment, getStandardDiagnosticRuleSet } from '../common/configOptions';
15+
import {
16+
ConfigOptions,
17+
ExecutionEnvironment,
18+
getStandardDiagnosticRuleSet,
19+
} from '../common/configOptions';
1620
import { ConsoleInterface, NullConsole } from '../common/console';
1721
import { TaskListPriority } from '../common/diagnostic';
1822
import { combinePaths, normalizePath, normalizeSlashes } from '../common/pathUtils';
@@ -740,4 +744,76 @@ describe(`config test'}`, () => {
740744
shouldRunAnalysis: () => true,
741745
});
742746
}
747+
748+
describe('glob root support', () => {
749+
function setupExecEnvConfig(roots: ({ root: string } & Record<string, unknown>)[]) {
750+
const cwd = UriEx.file(normalizePath(process.cwd()));
751+
const configOptions = new ConfigOptions(cwd);
752+
const json = { executionEnvironments: roots };
753+
const fs = new TestFileSystem(false);
754+
const console = new ErrorTrackingNullConsole();
755+
const sp = createServiceProvider(fs, console);
756+
configOptions.initializeFromJson(json, cwd, sp, new NoAccessHost());
757+
configOptions.setupExecutionEnvironments(json, cwd, console);
758+
return { cwd, configOptions, console };
759+
}
760+
761+
test.each([
762+
'src', '**/tests', 'src/*/utils', 'src/test?', '***/tests', ' ',
763+
])('root "%s" sets rootGlob', (root) => {
764+
const { configOptions, console } = setupExecEnvConfig([{ root }]);
765+
assert.deepStrictEqual(console.errors, []);
766+
const env = configOptions.executionEnvironments[0];
767+
assert.ok(env);
768+
assert.strictEqual(env.rootGlob, root);
769+
assert.ok(env.root);
770+
});
771+
772+
test('serialization round-trip preserves rootGlob', () => {
773+
const { configOptions } = setupExecEnvConfig([{ root: '**/tests' }]);
774+
const cloned = deserialize<ConfigOptions>(serialize(configOptions));
775+
assert.strictEqual(
776+
cloned.executionEnvironments[0].rootGlob,
777+
configOptions.executionEnvironments[0].rootGlob
778+
);
779+
});
780+
781+
test.each([
782+
{ roots: ['**/tests'], file: 'tests/test_foo.py', envIndex: 0 },
783+
{ roots: ['**/tests'], file: 'src/tests/test_foo.py', envIndex: 0 },
784+
{ roots: ['**/tests'], file: 'src/lib/deep/tests/test_bar.py', envIndex: 0 },
785+
{ roots: ['**/tests'], file: 'src/testing/foo.py', envIndex: -1 },
786+
{ roots: ['src/*/utils'], file: 'src/foo/utils/helper.py', envIndex: 0 },
787+
{ roots: ['src/*/utils'], file: 'src/foo/bar/utils/helper.py', envIndex: -1 },
788+
{ roots: ['src/v?/lib'], file: 'src/v1/lib/foo.py', envIndex: 0 },
789+
{ roots: ['src/v?/lib'], file: 'src/v10/lib/foo.py', envIndex: -1 },
790+
{ roots: ['src/core', '**/tests'], file: 'src/core/tests/test_foo.py', envIndex: 0 },
791+
{ roots: ['src/core', '**/tests'], file: 'lib/tests/test_bar.py', envIndex: 1 },
792+
{ roots: ['src/core', '**/tests'], file: 'other/module.py', envIndex: -1 },
793+
{ roots: ['**/tests', 'src'], file: 'src/tests/test_foo.py', envIndex: 0 },
794+
{ roots: ['**/tests', 'src'], file: 'src/module.py', envIndex: 1 },
795+
{ roots: ['**/tests', '**/test'], file: 'src/tests/foo.py', envIndex: 0 },
796+
{ roots: ['**/tests', '**/test'], file: 'src/test/foo.py', envIndex: 1 },
797+
{ roots: ['**/tests', '**/test'], file: 'src/tests/test/foo.py', envIndex: 0 },
798+
])('roots $roots: "$file" -> env $envIndex', ({ roots, file, envIndex }) => {
799+
const { cwd, configOptions } = setupExecEnvConfig(roots.map((root) => ({ root })));
800+
const result = configOptions.findExecEnvironment(cwd.resolvePaths(file));
801+
if (envIndex >= 0) {
802+
assert.strictEqual(result, configOptions.executionEnvironments[envIndex]);
803+
} else {
804+
for (const env of configOptions.executionEnvironments) {
805+
assert.notStrictEqual(result, env);
806+
}
807+
}
808+
});
809+
810+
test('glob root with diagnostic overrides', () => {
811+
const { configOptions } = setupExecEnvConfig([
812+
{ root: '**/tests', reportPrivateUsage: false },
813+
]);
814+
const env = configOptions.executionEnvironments[0];
815+
assert.strictEqual(env.root!.key, configOptions.projectRoot.key);
816+
assert.strictEqual(env.diagnosticRuleSet.reportPrivateUsage, 'none');
817+
});
818+
});
743819
});

0 commit comments

Comments
 (0)