From 69a0c5594a9e1f4040b8e85cc9212e38bd391c25 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 20 Apr 2021 21:51:39 -0700 Subject: [PATCH] Added support for pyproject.toml files. --- docs/configuration.md | 28 ++++ packages/pyright-internal/package-lock.json | 5 + packages/pyright-internal/package.json | 1 + .../pyright-internal/src/analyzer/service.ts | 157 ++++++++++++------ packages/vscode-pyright/package-lock.json | 5 + packages/vscode-pyright/package.json | 1 + 6 files changed, 148 insertions(+), 49 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 9e87b99a2..5630f0f6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,8 @@ Pyright offers flexible configuration options specified in a JSON-formatted text configuration. By default, the file is called “pyrightconfig.json” and is located within the root directory of your project. Multi-root workspaces (“Add Folder to Workspace…”) are supported, and each workspace root can have its own “pyrightconfig.json” file. +Pyright settings can also be specified in a `[tool.pyright]` section of a “pyproject.toml” file. A “pyrightconfig.json” file always takes precedent over “pyproject.toml” if both are present. + Relative paths specified within the config file are relative to the config file’s location. Paths with shell variables (including `~`) are not supported. ## Main Pyright Config Options @@ -223,6 +225,32 @@ The following is an example of a pyright config file: } ``` +## Sample Pyproject.toml File +```toml +[tool.pyright] +include = ["src"] +exclude = ["**/node_modules", + "**/__pycache__", + "src/experimental", + "src/typestubs" +] +ignore = ["src/oldstuff"] +stubPath = "src/stubs" +venv = "env367" + +reportMissingImports = true +reportMissingTypeStubs = false + +pythonVersion = "3.6" +pythonPlatform = "Linux" + +executionEnvironments = [ + { root = "src/web", pythonVersion = "3.5", pythonPlatform = "Windows", extraPaths = [ "src/service_libs" ] }, + { root = "src/sdk", pythonVersion = "3.0", extraPaths = [ "src/backend" ] }, + { root = "src/tests", extraPaths = ["src/tests/e2e", "src/sdk" ]}, + { root = "src" } +] +``` ## Diagnostic Rule Defaults diff --git a/packages/pyright-internal/package-lock.json b/packages/pyright-internal/package-lock.json index 9cf4ebf35..ca8772d0f 100644 --- a/packages/pyright-internal/package-lock.json +++ b/packages/pyright-internal/package-lock.json @@ -437,6 +437,11 @@ "minimist": "^1.2.0" } }, + "@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", diff --git a/packages/pyright-internal/package.json b/packages/pyright-internal/package.json index a7765db00..82ce8fddb 100644 --- a/packages/pyright-internal/package.json +++ b/packages/pyright-internal/package.json @@ -15,6 +15,7 @@ "test:coverage": "jest --forceExit --reporters=jest-junit --reporters=default --coverage --coverageReporters=cobertura --coverageReporters=html --coverageReporters=json" }, "dependencies": { + "@iarna/toml": "2.2.5", "assert": "^2.0.0", "chalk": "^4.1.0", "chokidar": "^3.5.1", diff --git a/packages/pyright-internal/src/analyzer/service.ts b/packages/pyright-internal/src/analyzer/service.ts index e224d027b..dde65520b 100644 --- a/packages/pyright-internal/src/analyzer/service.ts +++ b/packages/pyright-internal/src/analyzer/service.ts @@ -8,6 +8,7 @@ * Python files. */ +import * as TOML from '@iarna/toml'; import * as JSONC from 'jsonc-parser'; import { AbstractCancellationTokenSource, @@ -63,6 +64,7 @@ import { findPythonSearchPaths } from './pythonPathUtils'; import { TypeEvaluator } from './typeEvaluator'; export const configFileNames = ['pyrightconfig.json', 'mspythonconfig.json']; +export const pyprojectTomlName = 'pyproject.toml'; // How long since the last user activity should we wait until running // the analyzer on any files that have not yet been analyzed? @@ -430,6 +432,7 @@ export class AnalyzerService { private _getConfigOptions(commandLineOptions: CommandLineOptions): ConfigOptions { let projectRoot = commandLineOptions.executionRoot; let configFilePath: string | undefined; + let pyprojectFilePath: string | undefined; if (commandLineOptions.configFilePath) { // If the config file path was specified, determine whether it's @@ -455,13 +458,13 @@ export class AnalyzerService { } } else if (projectRoot) { // In a project-based IDE like VS Code, we should assume that the - // project root directory contains the config file. If pyright is - // being executed from the command line, the working directory - // may be deep within a project, and we need to walk up the directory - // hierarchy to find the project root. - if (commandLineOptions.fromVsCodeExtension) { - configFilePath = this._findConfigFile(projectRoot); - } else { + // project root directory contains the config file. + configFilePath = this._findConfigFile(projectRoot); + + // If pyright is being executed from the command line, the working + // directory may be deep within a project, and we need to walk up the + // directory hierarchy to find the project root. + if (!configFilePath && !commandLineOptions.fromVsCodeExtension) { configFilePath = this._findConfigFileHereOrUp(projectRoot); } @@ -473,6 +476,22 @@ export class AnalyzerService { } } + if (!configFilePath) { + // See if we can find a pyproject.toml file in this directory. + pyprojectFilePath = this._findPyprojectTomlFile(projectRoot); + + if (!pyprojectFilePath && !commandLineOptions.fromVsCodeExtension) { + pyprojectFilePath = this._findPyprojectTomlFileHereOrUp(projectRoot); + } + + if (pyprojectFilePath) { + projectRoot = getDirectoryPath(pyprojectFilePath); + this._console.info(`pyproject.toml file found at ${projectRoot}.`); + } else { + this._console.info(`No pyproject.toml file found.`); + } + } + const configOptions = new ConfigOptions(projectRoot, this._typeCheckingMode); const defaultExcludes = ['**/node_modules', '**/__pycache__', '.git']; @@ -504,41 +523,46 @@ export class AnalyzerService { } } - this._configFilePath = configFilePath; + this._configFilePath = configFilePath || pyprojectFilePath; // 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}`); - const configJsonObj = this._parseConfigFile(configFilePath); - if (configJsonObj) { - configOptions.initializeFromJson( - configJsonObj, - this._typeCheckingMode, - this._console, - commandLineOptions.diagnosticSeverityOverrides, - commandLineOptions.pythonPath, - commandLineOptions.fileSpecs.length > 0 - ); + configJsonObj = this._parseJsonConfigFile(configFilePath); + } else if (pyprojectFilePath) { + this._console.info(`Loading pyproject.toml file at ${pyprojectFilePath}`); + configJsonObj = this._parsePyprojectTomlFile(pyprojectFilePath); + } - const configFileDir = getDirectoryPath(configFilePath); + if (configJsonObj) { + configOptions.initializeFromJson( + configJsonObj, + this._typeCheckingMode, + this._console, + commandLineOptions.diagnosticSeverityOverrides, + commandLineOptions.pythonPath, + commandLineOptions.fileSpecs.length > 0 + ); - // If no include paths were provided, assume that all files within - // the project should be included. - if (configOptions.include.length === 0) { - this._console.info(`No include entries specified; assuming ${configFileDir}`); - configOptions.include.push(getFileSpec(configFileDir, '.')); - } + const configFileDir = getDirectoryPath(this._configFilePath!); - // If there was no explicit set of excludes, add a few common ones to avoid long scan times. - if (configOptions.exclude.length === 0) { - defaultExcludes.forEach((exclude) => { - this._console.info(`Auto-excluding ${exclude}`); - configOptions.exclude.push(getFileSpec(configFileDir, exclude)); - }); + // If no include paths were provided, assume that all files within + // the project should be included. + if (configOptions.include.length === 0) { + this._console.info(`No include entries specified; assuming ${configFileDir}`); + configOptions.include.push(getFileSpec(configFileDir, '.')); + } - if (configOptions.autoExcludeVenv === undefined) { - configOptions.autoExcludeVenv = true; - } + // If there was no explicit set of excludes, add a few common ones to avoid long scan times. + if (configOptions.exclude.length === 0) { + defaultExcludes.forEach((exclude) => { + this._console.info(`Auto-excluding ${exclude}`); + configOptions.exclude.push(getFileSpec(configFileDir, exclude)); + }); + + if (configOptions.autoExcludeVenv === undefined) { + configOptions.autoExcludeVenv = true; } } } else { @@ -803,27 +827,63 @@ export class AnalyzerService { return undefined; } - private _parseConfigFile(configPath: string): any | undefined { - let configContents = ''; + private _findPyprojectTomlFileHereOrUp(searchPath: string): string | undefined { + return forEachAncestorDirectory(searchPath, (ancestor) => this._findPyprojectTomlFile(ancestor)); + } + + private _findPyprojectTomlFile(searchPath: string) { + const fileName = combinePaths(searchPath, pyprojectTomlName); + if (this._fs.existsSync(fileName)) { + return fileName; + } + return undefined; + } + + private _parseJsonConfigFile(configPath: string): object | undefined { + return this._attemptParseFile(configPath, (fileContents) => { + return JSONC.parse(fileContents); + }); + } + + private _parsePyprojectTomlFile(pyprojectPath: string): object | undefined { + return this._attemptParseFile(pyprojectPath, (fileContents, attemptCount) => { + try { + const configObj = TOML.parse(fileContents); + if (configObj && configObj.tool && (configObj.tool as TOML.JsonMap).pyright) { + return (configObj.tool as TOML.JsonMap).pyright as object; + } + } catch (e) { + this._console.error(`Pyproject file parse attempt ${attemptCount} error: ${JSON.stringify(e)}`); + throw e; + } + + this._console.error(`Pyproject file "${pyprojectPath}" is missing "[tool.pyright] section.`); + return undefined; + }); + } + + private _attemptParseFile( + filePath: string, + parseCallback: (contents: string, attempt: number) => object | undefined + ): object | undefined { + let fileContents = ''; let parseAttemptCount = 0; while (true) { - // Attempt to read the config file contents. + // Attempt to read the file contents. try { - configContents = this._fs.readFileSync(configPath, 'utf8'); + fileContents = this._fs.readFileSync(filePath, 'utf8'); } catch { - this._console.error(`Config file "${configPath}" could not be read.`); + this._console.error(`Config file "${filePath}" could not be read.`); this._reportConfigParseError(); return undefined; } - // Attempt to parse the config file. - let configObj: any; + // Attempt to parse the file. let parseFailed = false; try { - configObj = JSONC.parse(configContents); - return configObj; - } catch { + return parseCallback(fileContents, parseAttemptCount + 1); + } catch (e) { parseFailed = true; } @@ -831,12 +891,11 @@ export class AnalyzerService { break; } - // If we attempt to read the config file immediately after it - // was saved, it may have been partially written when we read it, - // resulting in parse errors. We'll give it a little more time and - // try again. + // If we attempt to read the file immediately after it was saved, it + // may have been partially written when we read it, resulting in parse + // errors. We'll give it a little more time and try again. if (parseAttemptCount++ >= 5) { - this._console.error(`Config file "${configPath}" could not be parsed. Verify that JSON is correct.`); + this._console.error(`Config file "${filePath}" could not be parsed. Verify that format is correct.`); this._reportConfigParseError(); return undefined; } diff --git a/packages/vscode-pyright/package-lock.json b/packages/vscode-pyright/package-lock.json index 62f482573..c6dda2b93 100644 --- a/packages/vscode-pyright/package-lock.json +++ b/packages/vscode-pyright/package-lock.json @@ -10,6 +10,11 @@ "integrity": "sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg==", "dev": true }, + "@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, "@nodelib/fs.scandir": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz", diff --git a/packages/vscode-pyright/package.json b/packages/vscode-pyright/package.json index a78eb2874..19e10cd4e 100644 --- a/packages/vscode-pyright/package.json +++ b/packages/vscode-pyright/package.json @@ -750,6 +750,7 @@ "webpack-dev": "npm run clean && webpack --mode development --watch --progress" }, "dependencies": { + "@iarna/toml": "2.2.5", "vscode-jsonrpc": "6.0.0", "vscode-languageclient": "7.0.0", "vscode-languageserver": "7.0.0",