Added support for configuration inheritance through an "extends" configuration option. This addresses #4366. (#7997)

This commit is contained in:
Eric Traut 2024-05-24 17:22:12 -07:00 committed by GitHub
parent 0a83d6459c
commit 421dabee57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 187 additions and 63 deletions

View File

@ -16,6 +16,8 @@ Relative paths specified within the config file are relative to the config file
- **strict** [array of paths, optional]: Paths of directories or files that should use “strict” analysis if they are included. This is the same as manually adding a “# pyright: strict” comment. In strict mode, most type-checking rules are enabled. Refer to [this table](configuration.md#diagnostic-settings-defaults) for details about which rules are enabled in strict mode. Paths may contain wildcard characters ** (a directory or multiple levels of directories), * (a sequence of zero or more characters), or ? (a single character).
- **extends** [path, optional]: Path to another `.json` or `.toml` file that is used as a “base configuration”, allowing this configuration to inherit configuration settings. Top-level keys within this configuration overwrite top-level keys in the base configuration. Multiple levels of inheritance are supported. Relative paths specified in a configuration file are resolved relative to the location of that configuration file.
- **defineConstant** [map of constants to values (boolean or string), optional]: Set of identifiers that should be assumed to contain a constant value wherever used within this program. For example, `{ "DEBUG": true }` indicates that pyright should assume that the identifier `DEBUG` will always be equal to `True`. If this identifier is used within a conditional expression (such as `if not DEBUG:`) pyright will use the indicated value to determine whether the guarded block is reachable or not. Member expressions that reference one of these constants (e.g. `my_module.DEBUG`) are also supported.
- **typeshedPath** [path, optional]: Path to a directory that contains typeshed type stub files. Pyright ships with a bundled copy of typeshed type stubs. If you want to use a different version of typeshed stubs, you can clone the [typeshed github repo](https://github.com/python/typeshed) to a local directory and reference the location with this path. This option is useful if youre actively contributing updates to typeshed.

View File

@ -54,7 +54,7 @@ import { MaxAnalysisTime, Program } from './program';
import { findPythonSearchPaths } from './pythonPathUtils';
import { IPythonMode } from './sourceFile';
export const configFileNames = ['pyrightconfig.json'];
export const configFileName = 'pyrightconfig.json';
export const pyprojectTomlName = 'pyproject.toml';
// How long since the last user activity should we wait until running
@ -84,6 +84,11 @@ export interface AnalyzerServiceOptions {
fileSystem?: FileSystem;
}
interface ConfigFileContents {
configFileDirUri: Uri;
configFileJsonObj: object;
}
// Hold uniqueId for this service. It can be used to distinguish each service later.
let _nextServiceId = 1;
@ -103,7 +108,8 @@ export class AnalyzerService {
private _sourceFileWatcher: FileWatcher | undefined;
private _reloadConfigTimer: any;
private _libraryReanalysisTimer: any;
private _configFileUri: Uri | undefined;
private _primaryConfigFileUri: Uri | undefined;
private _extendedConfigFileUris: Uri[] = [];
private _configFileWatcher: FileWatcher | undefined;
private _libraryFileWatcher: FileWatcher | undefined;
private _librarySearchUrisToWatch: Uri[] | undefined;
@ -534,11 +540,12 @@ export class AnalyzerService {
? Uri.file(commandLineOptions.configFilePath, this.serviceProvider, /* checkRelative */ true)
: projectRoot.resolvePaths(commandLineOptions.configFilePath)
);
if (!this.fs.existsSync(configFilePath)) {
this._console.info(`Configuration file not found at ${configFilePath.toUserVisibleString()}.`);
configFilePath = projectRoot;
} else {
if (configFilePath.lastExtension.endsWith('.json')) {
if (configFilePath.lastExtension.endsWith('.json') || configFilePath.lastExtension.endsWith('.toml')) {
projectRoot = configFilePath.getDirectory();
} else {
projectRoot = configFilePath;
@ -648,30 +655,23 @@ export class AnalyzerService {
}
}
this._configFileUri = configFilePath || pyprojectFilePath;
configOptions.disableTaggedHints = !!commandLineOptions.disableTaggedHints;
// If we found a config file, parse it to compute the effective options.
let configJsonObj: object | undefined;
if (configFilePath) {
this._console.info(`Loading configuration file at ${configFilePath.toUserVisibleString()}`);
configJsonObj = this._parseJsonConfigFile(configFilePath);
} else if (pyprojectFilePath) {
this._console.info(`Loading pyproject.toml file at ${pyprojectFilePath.toUserVisibleString()}`);
configJsonObj = this._parsePyprojectTomlFile(pyprojectFilePath);
}
const configs = this._getExtendedConfigurations(configFilePath ?? pyprojectFilePath);
if (configJsonObj) {
configOptions.initializeFromJson(
configJsonObj,
this._typeCheckingMode,
this.serviceProvider,
host,
commandLineOptions
);
if (configs) {
for (const config of configs) {
configOptions.initializeFromJson(
config.configFileJsonObj,
config.configFileDirUri,
this._typeCheckingMode,
this.serviceProvider,
host,
commandLineOptions
);
}
const configFileDir = this._configFileUri!.getDirectory();
const configFileDir = this._primaryConfigFileUri!.getDirectory();
// If no include paths were provided, assume that all files within
// the project should be included.
@ -860,6 +860,61 @@ export class AnalyzerService {
return configOptions;
}
// Loads the config JSON object from the specified config file along with any
// chained config files specified in the "extends" property (recursively).
private _getExtendedConfigurations(primaryConfigFileUri: Uri | undefined): ConfigFileContents[] | undefined {
this._primaryConfigFileUri = primaryConfigFileUri;
this._extendedConfigFileUris = [];
if (!primaryConfigFileUri) {
return undefined;
}
let curConfigFileUri = primaryConfigFileUri;
const configJsonObjs: ConfigFileContents[] = [];
while (true) {
this._extendedConfigFileUris.push(curConfigFileUri);
let configFileJsonObj: object | undefined;
// Is this a TOML or JSON file?
if (curConfigFileUri.lastExtension.endsWith('.toml')) {
this._console.info(`Loading pyproject.toml file at ${curConfigFileUri.toUserVisibleString()}`);
configFileJsonObj = this._parsePyprojectTomlFile(curConfigFileUri);
} else {
this._console.info(`Loading configuration file at ${curConfigFileUri.toUserVisibleString()}`);
configFileJsonObj = this._parseJsonConfigFile(curConfigFileUri);
}
if (!configFileJsonObj) {
break;
}
// Push onto the start of the array so base configs are processed first.
configJsonObjs.unshift({ configFileJsonObj, configFileDirUri: curConfigFileUri.getDirectory() });
const baseConfigUri = ConfigOptions.resolveExtends(configFileJsonObj, curConfigFileUri.getDirectory());
if (!baseConfigUri) {
break;
}
// Check for circular references.
if (this._extendedConfigFileUris.some((uri) => uri.equals(baseConfigUri))) {
this._console.error(
`Circular reference in configuration file "extends" setting: ${curConfigFileUri.toUserVisibleString()} ` +
`extends ${baseConfigUri.toUserVisibleString()}`
);
break;
}
curConfigFileUri = baseConfigUri;
}
return configJsonObjs;
}
private _getTypeStubFolder() {
const stubPath =
this._configOptions.stubPath ??
@ -914,12 +969,11 @@ export class AnalyzerService {
}
private _findConfigFile(searchPath: Uri): Uri | undefined {
for (const name of configFileNames) {
const fileName = searchPath.resolvePaths(name);
if (this.fs.existsSync(fileName)) {
return this.fs.realCasePath(fileName);
}
const fileName = searchPath.resolvePaths(configFileName);
if (this.fs.existsSync(fileName)) {
return this.fs.realCasePath(fileName);
}
return undefined;
}
@ -1573,8 +1627,8 @@ export class AnalyzerService {
return;
}
if (this._configFileUri) {
this._configFileWatcher = this.fs.createFileSystemWatcher([this._configFileUri], (event) => {
if (this._primaryConfigFileUri) {
this._configFileWatcher = this.fs.createFileSystemWatcher(this._extendedConfigFileUris, (event) => {
if (this._verboseOutput) {
this._console.info(`Received fs event '${event}' for config file`);
}
@ -1588,7 +1642,7 @@ export class AnalyzerService {
if (event === 'add' || event === 'change') {
const fileName = getFileName(path);
if (fileName && configFileNames.some((name) => name === fileName)) {
if (fileName === configFileName) {
if (this._verboseOutput) {
this._console.info(`Received fs event '${event}' for config file`);
}
@ -1624,8 +1678,8 @@ export class AnalyzerService {
private _reloadConfigFile() {
this._updateConfigFileWatcher();
if (this._configFileUri) {
this._console.info(`Reloading configuration file at ${this._configFileUri.toUserVisibleString()}`);
if (this._primaryConfigFileUri) {
this._console.info(`Reloading configuration file at ${this._primaryConfigFileUri.toUserVisibleString()}`);
const host = this._backgroundAnalysisProgram.host;

View File

@ -19,8 +19,8 @@ import { DiagnosticRule } from './diagnosticRules';
import { FileSystem } from './fileSystem';
import { Host } from './host';
import { PythonVersion, latestStablePythonVersion } from './pythonVersion';
import { ServiceProvider } from './serviceProvider';
import { ServiceKeys } from './serviceKeys';
import { ServiceProvider } from './serviceProvider';
import { Uri } from './uri/uri';
import { FileSpec, getFileSpec, isDirectory } from './uri/uriUtils';
@ -1104,6 +1104,7 @@ export class ConfigOptions {
// Initialize the structure from a JSON object.
initializeFromJson(
configObj: any,
configDirUri: Uri,
typeCheckingMode: string | undefined,
serviceProvider: ServiceProvider,
host: Host,
@ -1125,7 +1126,7 @@ export class ConfigOptions {
} else if (isAbsolute(fileSpec)) {
console.error(`Ignoring path "${fileSpec}" in "include" array because it is not relative.`);
} else {
this.include.push(getFileSpec(this.projectRoot, fileSpec));
this.include.push(getFileSpec(configDirUri, fileSpec));
}
});
}
@ -1144,7 +1145,7 @@ export class ConfigOptions {
} else if (isAbsolute(fileSpec)) {
console.error(`Ignoring path "${fileSpec}" in "exclude" array because it is not relative.`);
} else {
this.exclude.push(getFileSpec(this.projectRoot, fileSpec));
this.exclude.push(getFileSpec(configDirUri, fileSpec));
}
});
}
@ -1163,7 +1164,7 @@ export class ConfigOptions {
} else if (isAbsolute(fileSpec)) {
console.error(`Ignoring path "${fileSpec}" in "ignore" array because it is not relative.`);
} else {
this.ignore.push(getFileSpec(this.projectRoot, fileSpec));
this.ignore.push(getFileSpec(configDirUri, fileSpec));
}
});
}
@ -1182,14 +1183,14 @@ export class ConfigOptions {
} else if (isAbsolute(fileSpec)) {
console.error(`Ignoring path "${fileSpec}" in "strict" array because it is not relative.`);
} else {
this.strict.push(getFileSpec(this.projectRoot, fileSpec));
this.strict.push(getFileSpec(configDirUri, fileSpec));
}
});
}
}
// If there is a "typeCheckingMode", it can override the provided setting.
let configTypeCheckingMode: string | undefined;
let configTypeCheckingMode: string | undefined = this.typeCheckingMode;
if (configObj.typeCheckingMode !== undefined) {
if (
configObj.typeCheckingMode === 'off' ||
@ -1239,17 +1240,15 @@ export class ConfigOptions {
});
// Read the "venvPath".
this.venvPath = undefined;
if (configObj.venvPath !== undefined) {
if (typeof configObj.venvPath !== 'string') {
console.error(`Config "venvPath" field must contain a string.`);
} else {
this.venvPath = this.projectRoot.resolvePaths(configObj.venvPath);
this.venvPath = configDirUri.resolvePaths(configObj.venvPath);
}
}
// Read the "venv" name.
this.venv = undefined;
if (configObj.venv !== undefined) {
if (typeof configObj.venv !== 'string') {
console.error(`Config "venv" field must contain a string.`);
@ -1269,7 +1268,7 @@ export class ConfigOptions {
if (typeof path !== 'string') {
console.error(`Config "extraPaths" field ${pathIndex} must be a string.`);
} else {
this.defaultExtraPaths!.push(this.projectRoot.resolvePaths(path));
this.defaultExtraPaths!.push(configDirUri.resolvePaths(path));
}
});
}
@ -1312,19 +1311,17 @@ export class ConfigOptions {
this.ensureDefaultPythonPlatform(host, console);
// Read the "typeshedPath" setting.
this.typeshedPath = undefined;
if (configObj.typeshedPath !== undefined) {
if (typeof configObj.typeshedPath !== 'string') {
console.error(`Config "typeshedPath" field must contain a string.`);
} else {
this.typeshedPath = configObj.typeshedPath
? this.projectRoot.resolvePaths(configObj.typeshedPath)
? configDirUri.resolvePaths(configObj.typeshedPath)
: undefined;
}
}
// Read the "stubPath" setting.
this.stubPath = undefined;
// Keep this for backward compatibility
if (configObj.typingsPath !== undefined) {
@ -1332,7 +1329,7 @@ export class ConfigOptions {
console.error(`Config "typingsPath" field must contain a string.`);
} else {
console.error(`Config "typingsPath" is now deprecated. Please, use stubPath instead.`);
this.stubPath = this.projectRoot.resolvePaths(configObj.typingsPath);
this.stubPath = configDirUri.resolvePaths(configObj.typingsPath);
}
}
@ -1340,7 +1337,7 @@ export class ConfigOptions {
if (typeof configObj.stubPath !== 'string') {
console.error(`Config "stubPath" field must contain a string.`);
} else {
this.stubPath = this.projectRoot.resolvePaths(configObj.stubPath);
this.stubPath = configDirUri.resolvePaths(configObj.stubPath);
}
}
@ -1384,14 +1381,23 @@ export class ConfigOptions {
// Read the "executionEnvironments" array. This should be done at the end
// after we've established default values.
this.executionEnvironments = [];
if (configObj.executionEnvironments !== undefined) {
if (!Array.isArray(configObj.executionEnvironments)) {
console.error(`Config "executionEnvironments" field must contain an array.`);
} else {
this.executionEnvironments = [];
const execEnvironments = configObj.executionEnvironments as ExecutionEnvironment[];
execEnvironments.forEach((env, index) => {
const execEnv = this._initExecutionEnvironmentFromJson(env, index, console, commandLineOptions);
const execEnv = this._initExecutionEnvironmentFromJson(
env,
configDirUri,
index,
console,
commandLineOptions
);
if (execEnv) {
this.executionEnvironments.push(execEnv);
}
@ -1450,6 +1456,18 @@ export class ConfigOptions {
}
}
static resolveExtends(configObj: any, configDirUri: Uri): Uri | undefined {
if (configObj.extends !== undefined) {
if (typeof configObj.extends !== 'string') {
console.error(`Config "extends" field must contain a string.`);
} else {
return configDirUri.resolvePaths(configObj.extends);
}
}
return undefined;
}
ensureDefaultPythonPlatform(host: Host, console: ConsoleInterface) {
// If no default python platform was specified, assume that the
// user wants to use the current platform.
@ -1552,6 +1570,7 @@ export class ConfigOptions {
private _initExecutionEnvironmentFromJson(
envObj: any,
configDirUri: Uri,
index: number,
console: ConsoleInterface,
commandLineOptions?: CommandLineOptions
@ -1559,7 +1578,7 @@ export class ConfigOptions {
try {
const newExecEnv = new ExecutionEnvironment(
this._getEnvironmentName(),
this.projectRoot,
configDirUri,
this.defaultPythonVersion,
this.defaultPythonPlatform,
this.defaultExtraPaths
@ -1567,7 +1586,7 @@ export class ConfigOptions {
// Validate the root.
if (envObj.root && typeof envObj.root === 'string') {
newExecEnv.root = this.projectRoot.resolvePaths(envObj.root);
newExecEnv.root = configDirUri.resolvePaths(envObj.root);
} else {
console.error(`Config executionEnvironments index ${index}: missing root value.`);
}
@ -1587,7 +1606,7 @@ export class ConfigOptions {
` extraPaths field ${pathIndex} must be a string.`
);
} else {
newExecEnv.extraPaths.push(this.projectRoot.resolvePaths(path));
newExecEnv.extraPaths.push(configDirUri.resolvePaths(path));
}
});
}

View File

@ -12,11 +12,11 @@ import {
FileSystemWatcher,
WatchKind,
} from 'vscode-languageserver';
import { DynamicFeature } from './dynamicFeature';
import { configFileNames } from '../analyzer/service';
import { configFileName } from '../analyzer/service';
import { FileSystem } from '../common/fileSystem';
import { deduplicateFolders, isFile } from '../common/uri/uriUtils';
import { WorkspaceFactory } from '../workspaceFactory';
import { FileSystem } from '../common/fileSystem';
import { DynamicFeature } from './dynamicFeature';
export class FileWatcherDynamicFeature extends DynamicFeature {
constructor(
@ -33,7 +33,7 @@ export class FileWatcherDynamicFeature extends DynamicFeature {
// Set default (config files and all workspace files) first.
const watchers: FileSystemWatcher[] = [
...configFileNames.map((fileName) => ({ globPattern: `**/${fileName}`, kind: watchKind })),
{ globPattern: `**/${configFileName}`, kind: watchKind },
{ globPattern: '**', kind: watchKind },
];

View File

@ -216,7 +216,7 @@ test('PythonPlatform', () => {
const nullConsole = new NullConsole();
const sp = createServiceProvider(fs, nullConsole);
configOptions.initializeFromJson(json, undefined, sp, new NoAccessHost());
configOptions.initializeFromJson(json, cwd, undefined, sp, new NoAccessHost());
const env = configOptions.executionEnvironments[0];
assert.strictEqual(env.pythonPlatform, 'platform');
@ -340,9 +340,10 @@ test('verify config fileSpecs after cloning', () => {
ignore: ['**/node_modules/**'],
};
const config = new ConfigOptions(Uri.file(process.cwd(), fs));
const rootUri = Uri.file(process.cwd(), fs);
const config = new ConfigOptions(rootUri);
const sp = createServiceProvider(fs, new NullConsole());
config.initializeFromJson(configFile, undefined, sp, new TestAccessHost());
config.initializeFromJson(configFile, rootUri, undefined, sp, new TestAccessHost());
const cloned = deserialize(serialize(config));
assert.deepEqual(config.ignore, cloned.ignore);
@ -371,3 +372,18 @@ test('extra paths on undefined execution root/default workspace', () => {
expectedExtraPaths.map((u) => u.getFilePath())
);
});
test('Extended config files', () => {
const cwd = normalizePath(combinePaths(process.cwd(), 'src/tests/samples/project_with_extended_config'));
const service = createAnalyzer();
const commandLineOptions = new CommandLineOptions(cwd, /* fromVsCodeExtension */ true);
service.setOptions(commandLineOptions);
const fileList = service.test_getFileNamesFromFileSpecs();
const fileNames = fileList.map((p) => p.fileName).sort();
assert.deepStrictEqual(fileNames, ['sample.pyi', 'test.py']);
const configOptions = service.test_getConfigOptions(commandLineOptions);
assert.equal(configOptions.typeCheckingMode, 'strict');
});

View File

@ -180,7 +180,13 @@ export class TestState {
const configOptions = this._convertGlobalOptionsToConfigOptions(vfsInfo.projectRoot, mountPaths);
if (this.rawConfigJson) {
configOptions.initializeFromJson(this.rawConfigJson, 'standard', this.serviceProvider, testAccessHost);
configOptions.initializeFromJson(
this.rawConfigJson,
Uri.file(projectRoot, this.serviceProvider),
'standard',
this.serviceProvider,
testAccessHost
);
this._applyTestConfigOptions(configOptions);
}

View File

@ -9,7 +9,7 @@
import assert from 'assert';
import * as JSONC from 'jsonc-parser';
import { configFileNames } from '../../../analyzer/service';
import { configFileName } from '../../../analyzer/service';
import { Comparison, toBoolean } from '../../../common/core';
import { combinePaths, getBaseFileName } from '../../../common/pathUtils';
import { getStringComparer } from '../../../common/stringUtils';
@ -76,5 +76,5 @@ export function getMarkerNames(testData: FourSlashData): string[] {
function isConfig(file: FourSlashFile, ignoreCase: boolean): boolean {
const comparer = getStringComparer(ignoreCase);
return configFileNames.some((f) => comparer(getBaseFileName(file.fileName), f) === Comparison.EqualTo);
return comparer(getBaseFileName(file.fileName), configFileName) === Comparison.EqualTo;
}

View File

@ -0,0 +1,3 @@
[tool.pyright]
extends = "sub2/baseconfig2.json"
typeCheckingMode = "strict"

View File

@ -0,0 +1,2 @@
[tool.pyright]
extends = "baseconfig1.toml"

View File

@ -0,0 +1,4 @@
{
"extends": "../sub3/baseconfig3.json",
"typeCheckingMode": "standard"
}

View File

@ -0,0 +1,4 @@
{
"stubPath": "stubs",
"typeCheckingMode": "basic"
}

View File

@ -0,0 +1,7 @@
# pyright: reportMissingModuleSource=false
from typing import assert_type
from sample import x
assert_type(x, int)

View File

@ -23,6 +23,12 @@
}
},
"properties": {
"extends": {
"$id": "#/properties/extends",
"type": "string",
"title": "Path to configuration file that this configuration extends",
"pattern": "^(.*)$"
},
"include": {
"$id": "#/properties/include",
"type": "array",