diff --git a/.prettierrc b/.prettierrc index a639031ac12b..3698ff51a3f5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,6 +4,7 @@ "useTabs": false, "printWidth": 120, "endOfLine": "auto", + "trailingComma": "es5", "overrides": [ { "files": ["*.yml", "*.yaml"], diff --git a/docs/configuration.md b/docs/configuration.md index 098e9d992ba9..cab3836d9927 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,8 @@ The following settings control the *environment* in which Pyright will check for - **extraPaths** [array of strings, optional]: Additional search paths that will be used when searching for modules imported by files. +- **namespaceOverridePaths** [array of strings, optional]: Paths that should be treated as "transparent" namespace packages. If an `__init__.py` file is found within one of these paths, it is ignored, and the import resolver continues to search other import roots for additional fragments of the package. This is useful in environments like Bazel where a single logical package may be split across multiple physical directories, some of which contain an `__init__.py` file. It can also be used for legacy `pkgutil` or `pkg_resources` namespaces where an `__init__.py` file is present but intended to allow merging with other directories. + - **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. - **pythonPlatform** [string, optional]: Specifies the target platform that will be used to execute the source code. Should be one of `"Windows"`, `"Darwin"`, `"Linux"`, or `"All"`. If specified, pyright will tailor its use of type stub files, which conditionalize type definitions based on the platform. If no platform is specified, pyright will use the current platform. @@ -246,6 +248,8 @@ The following settings can be specified for each execution environment. Each sou - **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 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. +- **namespaceOverridePaths** [array of strings, optional]: Paths that should be treated as "transparent" namespace packages. If specified, this overrides the default namespaceOverridePaths setting for files within this execution environment. This is useful in environments like Bazel or for legacy `pkgutil` or `pkg_resources` namespaces where an `__init__.py` file is present but intended to allow merging with other directories. + - **pythonVersion** [string, optional]: The version of Python used for this execution environment. If not specified, the global `pythonVersion` setting is used instead. - **pythonPlatform** [string, optional]: Specifies the target platform that will be used for this execution environment. If not specified, the global `pythonPlatform` setting is used instead. diff --git a/docs/settings.md b/docs/settings.md index 8f3bef9aed30..a186751b0b1c 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -24,6 +24,8 @@ The Pyright language server honors the following settings. **python.analysis.extraPaths** [array of paths]: Paths to add to the default execution environment extra paths if there are no execution environments defined in the config file. +**python.analysis.namespaceOverridePaths** [array of paths]: Paths to add to the default execution environment namespace override paths if there are no execution environments defined in the config file. + **python.analysis.ignore** [array of paths]: Paths of directories or files whose diagnostic output (errors and warnings) should be suppressed. This can be overridden in the configuration file. **python.analysis.include** [array of paths]: Paths of directories or files that should be included. This can be overridden in the configuration file. diff --git a/packages/pyright-internal/src/analyzer/importResolver.ts b/packages/pyright-internal/src/analyzer/importResolver.ts index cb21e59d59c7..cafc7c723828 100644 --- a/packages/pyright-internal/src/analyzer/importResolver.ts +++ b/packages/pyright-internal/src/analyzer/importResolver.ts @@ -572,6 +572,12 @@ export class ImportResolver { return importResult; } + // If it was shadowed by a regular package, we should not proceed to local imports + // (parent directory resolution). This maintains standard shadowing behavior. + if (importResult.isShadowed) { + return importResult; + } + // If the import is absolute and no other method works, try resolving the // absolute in the importing file's directory, then the parent directory, // and so on, until the import root is reached. @@ -975,6 +981,7 @@ export class ImportResolver { isNamespacePackage: false, isInitFilePresent: false, isStubPackage: false, + isShadowed: false, importFailureInfo: importLogger?.getLogs(), resolvedUris: [], importType: ImportType.Local, @@ -1356,6 +1363,14 @@ export class ImportResolver { }; } + private _isNamespaceOverridePath(uri: Uri, execEnv: ExecutionEnvironment): boolean { + if (!execEnv.namespaceOverridePaths || execEnv.namespaceOverridePaths.length === 0) { + return false; + } + + return execEnv.namespaceOverridePaths.some((path) => uri.equals(path)); + } + private _invalidateFileSystemCache() { this._cachedEntriesForPath.clear(); this._cachedFilesForPath.clear(); @@ -1400,11 +1415,23 @@ export class ImportResolver { if (allowPyi && this.fileExistsCached(pyiFilePath)) { importLogger?.log(`Resolved import with file '${pyiFilePath}'`); - resolvedPaths.push(pyiFilePath); - isStubFile = true; + if (this._isNamespaceOverridePath(dirPath, execEnv)) { + importLogger?.log(`Treating as namespace package due to namespaceOverridePaths`); + resolvedPaths.push(Uri.empty()); + isNamespacePackage = true; + } else { + resolvedPaths.push(pyiFilePath); + isStubFile = true; + } } else if (this.fileExistsCached(pyFilePath)) { importLogger?.log(`Resolved import with file '${pyFilePath}'`); - resolvedPaths.push(pyFilePath); + if (this._isNamespaceOverridePath(dirPath, execEnv)) { + importLogger?.log(`Treating as namespace package due to namespaceOverridePaths`); + resolvedPaths.push(Uri.empty()); + isNamespacePackage = true; + } else { + resolvedPaths.push(pyFilePath); + } } else { importLogger?.log(`Partially resolved import with directory '${dirPath}'`); resolvedPaths.push(Uri.empty()); @@ -1413,6 +1440,8 @@ export class ImportResolver { implicitImports = this.findImplicitImports(importName, dirPath, [pyFilePath, pyiFilePath]); } else { + let lastFoundDirectory: Uri | undefined; + for (let i = 0; i < moduleDescriptor.nameParts.length; i++) { const isFirstPart = i === 0; const isLastPart = i === moduleDescriptor.nameParts.length - 1; @@ -1429,6 +1458,7 @@ export class ImportResolver { if (isFirstPart) { packageDirectory = dirPath; } + lastFoundDirectory = dirPath; // See if we can find an __init__.py[i] in this directory. const pyFilePath = dirPath.initPyUri; @@ -1437,15 +1467,23 @@ export class ImportResolver { if (allowPyi && this.fileExistsCached(pyiFilePath)) { importLogger?.log(`Resolved import with file '${pyiFilePath}'`); - resolvedPaths.push(pyiFilePath); - if (isLastPart) { - isStubFile = true; + if (this._isNamespaceOverridePath(dirPath, execEnv)) { + importLogger?.log(`Treating as namespace package due to namespaceOverridePaths`); + } else { + resolvedPaths.push(pyiFilePath); + if (isLastPart) { + isStubFile = true; + } + isInitFilePresent = true; } - isInitFilePresent = true; } else if (this.fileExistsCached(pyFilePath)) { importLogger?.log(`Resolved import with file '${pyFilePath}'`); - resolvedPaths.push(pyFilePath); - isInitFilePresent = true; + if (this._isNamespaceOverridePath(dirPath, execEnv)) { + importLogger?.log(`Treating as namespace package due to namespaceOverridePaths`); + } else { + resolvedPaths.push(pyFilePath); + isInitFilePresent = true; + } } if (!pyTypedInfo && lookForPyTyped) { @@ -1458,11 +1496,6 @@ export class ImportResolver { // so continue to look for the next part. continue; } - - implicitImports = this.findImplicitImports(moduleDescriptor.nameParts.join('.'), dirPath, [ - pyFilePath, - pyiFilePath, - ]); break; } } @@ -1512,7 +1545,6 @@ export class ImportResolver { resolvedPaths.push(Uri.empty()); if (isLastPart) { - implicitImports = this.findImplicitImports(importName, dirPath, [pyFilePath, pyiFilePath]); isNamespacePackage = true; } } @@ -1522,6 +1554,17 @@ export class ImportResolver { } break; } + + if (lastFoundDirectory && resolvedPaths.length > 0) { + const lastResolvedPath = resolvedPaths[resolvedPaths.length - 1]; + if (lastResolvedPath.isEmpty() || lastResolvedPath.fileName.startsWith('__init__')) { + const resolvedName = moduleDescriptor.nameParts.slice(0, resolvedPaths.length).join('.'); + implicitImports = this.findImplicitImports(resolvedName, lastFoundDirectory, [ + lastFoundDirectory.initPyUri, + lastFoundDirectory.initPyiUri, + ]); + } + } } let importFound: boolean; @@ -1538,6 +1581,7 @@ export class ImportResolver { isNamespacePackage, isInitFilePresent, isStubPackage, + isShadowed: false, isImportFound: importFound, isPartlyResolved, importFailureInfo: importLogger?.getLogs(), @@ -1669,7 +1713,13 @@ export class ImportResolver { allowPyi, /* lookForPyTyped */ false ); - bestResultSoFar = localImport; + if (localImport) { + bestResultSoFar = localImport; + if ((localImport.isImportFound || localImport.isPartlyResolved) && !localImport.isNamespacePackage) { + bestResultSoFar.isShadowed = true; + return bestResultSoFar; + } + } } for (const extraPath of execEnv.extraPaths) { @@ -1687,7 +1737,18 @@ export class ImportResolver { allowPyi, /* lookForPyTyped */ false ); - bestResultSoFar = this._pickBestImport(bestResultSoFar, localImport, moduleDescriptor); + if (localImport) { + const wasShadowed = bestResultSoFar?.isShadowed; + bestResultSoFar = this._pickBestImport(bestResultSoFar, localImport, moduleDescriptor); + if (wasShadowed) { + bestResultSoFar.isShadowed = true; + } + + if ((localImport.isImportFound || localImport.isPartlyResolved) && !localImport.isNamespacePackage) { + bestResultSoFar.isShadowed = true; + break; + } + } } // Check for a stdlib typeshed file. @@ -1730,7 +1791,19 @@ export class ImportResolver { if (thirdPartyImport) { thirdPartyImport.importType = ImportType.ThirdParty; + const wasShadowed = bestResultSoFar?.isShadowed; bestResultSoFar = this._pickBestImport(bestResultSoFar, thirdPartyImport, moduleDescriptor); + if (wasShadowed) { + bestResultSoFar.isShadowed = true; + } + + if ( + (thirdPartyImport.isImportFound || thirdPartyImport.isPartlyResolved) && + !thirdPartyImport.isNamespacePackage + ) { + bestResultSoFar.isShadowed = true; + break; + } } } } else { @@ -1786,6 +1859,62 @@ export class ImportResolver { bestImportSoFar: ImportResult | undefined, newImport: ImportResult | undefined, moduleDescriptor: ImportedModuleDescriptor + ): ImportResult { + if (!bestImportSoFar) { + return newImport!; + } + + if (!newImport) { + return bestImportSoFar; + } + + const best = this._pickBestImportInternal(bestImportSoFar, newImport, moduleDescriptor); + const other = best === bestImportSoFar ? newImport : bestImportSoFar; + + if ( + best && + other && + (best.isImportFound || best.isPartlyResolved) && + (other.isImportFound || other.isPartlyResolved) + ) { + if (best.isNamespacePackage || other.isNamespacePackage) { + this._mergeImplicitImports(best, other); + if (other.isNamespacePackage) { + best.isNamespacePackage = true; + } + } + } + + return best!; + } + + private _mergeImplicitImports(dest: ImportResult, src: ImportResult) { + if (!src.implicitImports || src.implicitImports.size === 0) { + return; + } + + if (!dest.implicitImports) { + dest.implicitImports = new Map(); + } else if (dest.implicitImports === src.implicitImports) { + return; + } + + src.implicitImports.forEach((implImport, name) => { + if (!dest.implicitImports!.has(name)) { + dest.implicitImports!.set(name, implImport); + } else { + const existing = dest.implicitImports!.get(name)!; + if (!existing.isStubFile && implImport.isStubFile) { + dest.implicitImports!.set(name, implImport); + } + } + }); + } + + private _pickBestImportInternal( + bestImportSoFar: ImportResult | undefined, + newImport: ImportResult | undefined, + moduleDescriptor: ImportedModuleDescriptor ) { if (!bestImportSoFar) { return newImport; diff --git a/packages/pyright-internal/src/analyzer/importResult.ts b/packages/pyright-internal/src/analyzer/importResult.ts index c1a2d4efa6ed..274f1ff8f817 100644 --- a/packages/pyright-internal/src/analyzer/importResult.ts +++ b/packages/pyright-internal/src/analyzer/importResult.ts @@ -50,6 +50,9 @@ export interface ImportResult { // directory resolved. isInitFilePresent: boolean; + // True if the import was shadowed by a regular package. + isShadowed?: boolean; + // Did it resolve to a stub within a stub package? isStubPackage: boolean; diff --git a/packages/pyright-internal/src/analyzer/service.ts b/packages/pyright-internal/src/analyzer/service.ts index 89eb73a9901b..cbeae5ef09ed 100644 --- a/packages/pyright-internal/src/analyzer/service.ts +++ b/packages/pyright-internal/src/analyzer/service.ts @@ -941,6 +941,14 @@ export class AnalyzerService { ); } + if (!configOptions.defaultNamespaceOverridePaths && commandLineOptions.configSettings.namespaceOverridePaths) { + // Convert user-provided namespace override path strings into absolute Uris + // relative to the execution root. + configOptions.defaultNamespaceOverridePaths = commandLineOptions.configSettings.namespaceOverridePaths.map( + (p) => executionRoot.combinePaths(p) + ); + } + if (configOptions.defaultPythonPlatform === undefined) { configOptions.defaultPythonPlatform = commandLineOptions.configSettings.pythonPlatform; } diff --git a/packages/pyright-internal/src/common/commandLineOptions.ts b/packages/pyright-internal/src/common/commandLineOptions.ts index 588ae97c33f4..964027a87b80 100644 --- a/packages/pyright-internal/src/common/commandLineOptions.ts +++ b/packages/pyright-internal/src/common/commandLineOptions.ts @@ -88,6 +88,10 @@ export class CommandLineConfigOptions { // when user has not explicitly defined execution environments. extraPaths?: string[] | undefined; + // Namespace override paths to add to the default execution environment + // when user has not explicitly defined execution environments. + namespaceOverridePaths?: string[] | undefined; + // Default type-checking rule set. Should be one of 'off', // 'basic', 'standard', or 'strict'. typeCheckingMode?: string | undefined; diff --git a/packages/pyright-internal/src/common/configOptions.ts b/packages/pyright-internal/src/common/configOptions.ts index 7cb1af20ff30..58809fac135a 100644 --- a/packages/pyright-internal/src/common/configOptions.ts +++ b/packages/pyright-internal/src/common/configOptions.ts @@ -55,6 +55,10 @@ export class ExecutionEnvironment { // Default to no extra paths. extraPaths: Uri[] = []; + // Paths that should be treated as namespace packages even if they + // have an __init__.py file. + namespaceOverridePaths?: Uri[] | undefined; + // Diagnostic rules with overrides. diagnosticRuleSet: DiagnosticRuleSet; @@ -71,6 +75,7 @@ export class ExecutionEnvironment { defaultPythonVersion: PythonVersion | undefined, defaultPythonPlatform: string | undefined, defaultExtraPaths: Uri[] | undefined, + defaultNamespaceOverridePaths?: Uri[] | undefined, skipNativeLibraries = false ) { this.name = name; @@ -78,6 +83,7 @@ export class ExecutionEnvironment { this.pythonVersion = defaultPythonVersion ?? latestStablePythonVersion; this.pythonPlatform = defaultPythonPlatform; this.extraPaths = Array.from(defaultExtraPaths ?? []); + this.namespaceOverridePaths = Array.from(defaultNamespaceOverridePaths ?? []); this.diagnosticRuleSet = { ...defaultDiagRuleSet }; this.skipNativeLibraries = skipNativeLibraries; } @@ -1055,6 +1061,9 @@ export class ConfigOptions { // Default extraPaths. Can be overridden by executionEnvironment. defaultExtraPaths?: Uri[] | undefined; + // Default namespaceOverridePaths. Can be overridden by executionEnvironment. + defaultNamespaceOverridePaths?: Uri[] | undefined; + // Should native library import resolutions be skipped? skipNativeLibraries?: boolean; @@ -1110,6 +1119,7 @@ export class ConfigOptions { this.defaultPythonVersion, this.defaultPythonPlatform, this.defaultExtraPaths, + this.defaultNamespaceOverridePaths, this.skipNativeLibraries ); } @@ -1321,6 +1331,25 @@ export class ConfigOptions { } } + // Read the config "namespaceOverridePaths". + const configNamespaceOverridePaths: Uri[] = []; + if (configObj.namespaceOverridePaths !== undefined) { + unusedConfigKeys.delete('namespaceOverridePaths'); + if (!Array.isArray(configObj.namespaceOverridePaths)) { + console.error(`Config "namespaceOverridePaths" field must contain an array.`); + } else { + const pathList = configObj.namespaceOverridePaths as string[]; + pathList.forEach((path, pathIndex) => { + if (typeof path !== 'string') { + console.error(`Config "namespaceOverridePaths" field ${pathIndex} must be a string.`); + } else { + configNamespaceOverridePaths!.push(configDirUri.resolvePaths(path)); + } + }); + this.defaultNamespaceOverridePaths = [...configNamespaceOverridePaths]; + } + } + // Read the default "pythonVersion". if (configObj.pythonVersion !== undefined) { unusedConfigKeys.delete('pythonVersion'); @@ -1608,7 +1637,8 @@ export class ConfigOptions { this.diagnosticRuleSet, this.defaultPythonVersion, this.defaultPythonPlatform, - this.defaultExtraPaths || [] + this.defaultExtraPaths || [], + this.defaultNamespaceOverridePaths || [] ); if (execEnv) { @@ -1657,7 +1687,8 @@ export class ConfigOptions { configDiagnosticRuleSet: DiagnosticRuleSet, configPythonVersion: PythonVersion | undefined, configPythonPlatform: string | undefined, - configExtraPaths: Uri[] + configExtraPaths: Uri[], + configNamespaceOverridePaths: Uri[] ): ExecutionEnvironment | undefined { try { const envObjKeys = envObj && typeof envObj === 'object' ? Object.getOwnPropertyNames(envObj) : []; @@ -1669,7 +1700,8 @@ export class ConfigOptions { configDiagnosticRuleSet, configPythonVersion, configPythonPlatform, - configExtraPaths + configExtraPaths, + configNamespaceOverridePaths ); // Validate the root. @@ -1706,6 +1738,32 @@ export class ConfigOptions { } } + // Validate the namespaceOverridePaths. + unusedEnvKeys.delete('namespaceOverridePaths'); + if (envObj.namespaceOverridePaths) { + if (!Array.isArray(envObj.namespaceOverridePaths)) { + console.error( + `Config executionEnvironments index ${index}: namespaceOverridePaths field must contain an array.` + ); + } else { + // If specified, this overrides the default extra paths inherited + // from the top-level config. + const namespaceOverridePaths: Uri[] = []; + const pathList = envObj.namespaceOverridePaths as string[]; + pathList.forEach((path, pathIndex) => { + if (typeof path !== 'string') { + console.error( + `Config executionEnvironments index ${index}:` + + ` namespaceOverridePaths field ${pathIndex} must be a string.` + ); + } else { + namespaceOverridePaths.push(configDirUri.resolvePaths(path)); + } + }); + newExecEnv.namespaceOverridePaths = namespaceOverridePaths; + } + } + // Validate the pythonVersion. unusedEnvKeys.delete('pythonVersion'); if (envObj.pythonVersion) { diff --git a/packages/pyright-internal/src/common/languageServerInterface.ts b/packages/pyright-internal/src/common/languageServerInterface.ts index 4601950760fe..e4e0d1ec5d30 100644 --- a/packages/pyright-internal/src/common/languageServerInterface.ts +++ b/packages/pyright-internal/src/common/languageServerInterface.ts @@ -31,6 +31,7 @@ export interface ServerSettings { disableOrganizeImports?: boolean | undefined; autoSearchPaths?: boolean | undefined; extraPaths?: Uri[] | undefined; + namespaceOverridePaths?: Uri[] | undefined; watchForSourceChanges?: boolean | undefined; watchForLibraryChanges?: boolean | undefined; watchForConfigChanges?: boolean | undefined; diff --git a/packages/pyright-internal/src/languageService/analyzerServiceExecutor.ts b/packages/pyright-internal/src/languageService/analyzerServiceExecutor.ts index cae66a9b1f3a..090246428747 100644 --- a/packages/pyright-internal/src/languageService/analyzerServiceExecutor.ts +++ b/packages/pyright-internal/src/languageService/analyzerServiceExecutor.ts @@ -154,6 +154,8 @@ export function getEffectiveCommandLineOptions( commandLineOptions.configSettings.autoSearchPaths = serverSettings.autoSearchPaths; commandLineOptions.configSettings.extraPaths = serverSettings.extraPaths?.map((e) => e.getFilePath()) ?? []; + commandLineOptions.configSettings.namespaceOverridePaths = + serverSettings.namespaceOverridePaths?.map((e) => e.getFilePath()) ?? []; commandLineOptions.configSettings.diagnosticSeverityOverrides = serverSettings.diagnosticSeverityOverrides; commandLineOptions.configSettings.diagnosticBooleanOverrides = serverSettings.diagnosticBooleanOverrides; diff --git a/packages/pyright-internal/src/server.ts b/packages/pyright-internal/src/server.ts index 3611999bd011..496a1d23ed5b 100644 --- a/packages/pyright-internal/src/server.ts +++ b/packages/pyright-internal/src/server.ts @@ -172,6 +172,14 @@ export class PyrightServer extends LanguageServerBase { .filter(isDefined); } + const namespaceOverridePaths = pythonAnalysisSection.namespaceOverridePaths; + if (namespaceOverridePaths && Array.isArray(namespaceOverridePaths) && namespaceOverridePaths.length > 0) { + serverSettings.namespaceOverridePaths = namespaceOverridePaths + .filter((p) => p && isString(p)) + .map((p) => resolvePathWithEnvVariables(workspace, p, workspaces)) + .filter(isDefined); + } + serverSettings.includeFileSpecs = this._getStringValues(pythonAnalysisSection.include); serverSettings.excludeFileSpecs = this._getStringValues(pythonAnalysisSection.exclude); serverSettings.ignoreFileSpecs = this._getStringValues(pythonAnalysisSection.ignore); diff --git a/packages/pyright-internal/src/tests/importResolver.test.ts b/packages/pyright-internal/src/tests/importResolver.test.ts index 2ab718fd0c4b..5044b81a8f20 100644 --- a/packages/pyright-internal/src/tests/importResolver.test.ts +++ b/packages/pyright-internal/src/tests/importResolver.test.ts @@ -801,6 +801,79 @@ describe('Import tests with fake venv', () => { }); }); + describe('Namespace Package Override', () => { + const commonFiles = [ + { path: combinePaths('/', 'root1', 'pkg', '__init__.py'), content: '' }, + { path: combinePaths('/', 'root1', 'pkg', 'only_in_1.py'), content: '' }, + { path: combinePaths('/', 'root2', 'pkg', '__init__.py'), content: '' }, + { path: combinePaths('/', 'root2', 'pkg', 'only_in_2.py'), content: '' }, + ]; + + test('Standard behavior: first __init__.py shadows subsequent roots', () => { + const importResult = getImportResult(commonFiles, ['pkg', 'only_in_2'], (config) => { + config.defaultExtraPaths = [ + UriEx.file(combinePaths('/', 'root1')), + UriEx.file(combinePaths('/', 'root2')), + ]; + }); + + assert(!importResult.isImportFound); + assert.strictEqual(importResult.isInitFilePresent, true); + }); + + test('Override behavior: allows merging across roots despite __init__.py', () => { + const importResult = getImportResult(commonFiles, ['pkg', 'only_in_2'], (config) => { + config.defaultExtraPaths = [ + UriEx.file(combinePaths('/', 'root1')), + UriEx.file(combinePaths('/', 'root2')), + ]; + config.defaultNamespaceOverridePaths = [UriEx.file(combinePaths('/', 'root1', 'pkg'))]; + }); + + assert(importResult.isImportFound); + // resolvedUris should be: [Uri(pkg), Uri(only_in_2.py)] -> Length 2 + // If the getImportResult helper includes the root, length might be 3. + // The key is that the last element is the correct file. + const lastUri = importResult.resolvedUris[importResult.resolvedUris.length - 1]; + assert.strictEqual(lastUri.getFilePath(), combinePaths('/', 'root2', 'pkg', 'only_in_2.py')); + + assert.strictEqual(importResult.isNamespacePackage, true); + }); + test('Triple merge: tunneling through multiple transparent blockers', () => { + const tripleFiles = [ + { path: combinePaths('/', 'root1', 'app', '__init__.py'), content: '' }, + { path: combinePaths('/', 'root1', 'app', 'core.py'), content: '' }, + + { path: combinePaths('/', 'root2', 'app', '__init__.py'), content: '' }, + { path: combinePaths('/', 'root2', 'app', 'generated.py'), content: '' }, + + { path: combinePaths('/', 'root3', 'app', '__init__.py'), content: '' }, + { path: combinePaths('/', 'root3', 'app', 'utils.py'), content: '' }, + ]; + + const importResult = getImportResult(tripleFiles, ['app'], (config) => { + config.defaultExtraPaths = [ + UriEx.file(combinePaths('/', 'root1')), + UriEx.file(combinePaths('/', 'root2')), + UriEx.file(combinePaths('/', 'root3')), + ]; + + config.defaultNamespaceOverridePaths = [ + UriEx.file(combinePaths('/', 'root1', 'app')), + UriEx.file(combinePaths('/', 'root2', 'app')), + ]; + }); + + assert(importResult.isImportFound, 'Should resolve app by merging roots'); + + // This checks if the analyzer sees the modules from all roots + // because they were merged into the namespace. + assert(importResult.implicitImports?.has('core'), 'Namespace should contain "core" from root1'); + assert(importResult.implicitImports?.has('generated'), 'Namespace should contain "generated" from root2'); + assert(importResult.implicitImports?.has('utils'), 'Namespace should contain "utils" from root3'); + }); + }); + if (usingTrueVenv()) { describe('Import tests that have to run with a venv', () => { test('venv can find imports', () => { diff --git a/packages/vscode-pyright/package.json b/packages/vscode-pyright/package.json index ad63927f9820..99ad3e9236ee 100644 --- a/packages/vscode-pyright/package.json +++ b/packages/vscode-pyright/package.json @@ -140,6 +140,15 @@ "description": "Additional import search resolution paths", "scope": "resource" }, + "python.analysis.namespaceOverridePaths": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "description": "Paths that should be treated as \"transparent\" namespace packages.", + "scope": "resource" + }, "python.analysis.stubPath": { "type": "string", "default": "typings", diff --git a/packages/vscode-pyright/schemas/pyrightconfig.schema.json b/packages/vscode-pyright/schemas/pyrightconfig.schema.json index 6fc64e5167c2..af855b26d0a4 100644 --- a/packages/vscode-pyright/schemas/pyrightconfig.schema.json +++ b/packages/vscode-pyright/schemas/pyrightconfig.schema.json @@ -31,6 +31,16 @@ "pattern": "^(.*)$" } }, + "namespaceOverridePaths": { + "type": "array", + "title": "Paths that should be treated as namespace packages", + "items": { + "type": "string", + "title": "Path to a directory that should be treated as a namespace package", + "default": "", + "pattern": "^(.*)$" + } + }, "pythonVersion": { "type": "string", "title": "Python version to assume during type analysis", @@ -864,6 +874,9 @@ "extraPaths": { "$ref": "#/definitions/extraPaths" }, + "namespaceOverridePaths": { + "$ref": "#/definitions/namespaceOverridePaths" + }, "pythonVersion": { "$ref": "#/definitions/pythonVersion" }, @@ -1182,6 +1195,9 @@ "extraPaths": { "$ref": "#/definitions/extraPaths" }, + "namespaceOverridePaths": { + "$ref": "#/definitions/namespaceOverridePaths" + }, "pythonVersion": { "$ref": "#/definitions/pythonVersion" },