From 815b391eae003be85f578d4147c504cf47aa2db7 Mon Sep 17 00:00:00 2001 From: Greg Friedman Date: Fri, 20 Feb 2026 11:20:30 -0500 Subject: [PATCH 1/4] Add noImplicitReexport config option (plumbing only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new boolean diagnostic rule `noImplicitReexport` (default: true) that mirrors mypy's `no_implicit_reexport` flag. No behavior change yet — this commit adds the enum value, DiagnosticRuleSet field, preset defaults, and JSON schema definition/references. --- packages/pyright-internal/src/common/configOptions.ts | 9 +++++++++ .../pyright-internal/src/common/diagnosticRules.ts | 1 + .../vscode-pyright/schemas/pyrightconfig.schema.json | 11 +++++++++++ 3 files changed, 21 insertions(+) diff --git a/packages/pyright-internal/src/common/configOptions.ts b/packages/pyright-internal/src/common/configOptions.ts index 484469ee1399..471ba0d35749 100644 --- a/packages/pyright-internal/src/common/configOptions.ts +++ b/packages/pyright-internal/src/common/configOptions.ts @@ -141,6 +141,10 @@ export interface DiagnosticRuleSet { // No longer treat bytearray and memoryview as subclasses of bytes? disableBytesTypePromotions: boolean; + // Treat plain `from X import Y` imports in py.typed packages as re-exports + // for public names? Mirrors mypy's no_implicit_reexport setting. + noImplicitReexport: boolean; + // Report general type issues? reportGeneralTypeIssues: DiagnosticLevel; @@ -417,6 +421,7 @@ export function getBooleanDiagnosticRules(includeNonOverridable = false) { DiagnosticRule.enableExperimentalFeatures, DiagnosticRule.deprecateTypingAliases, DiagnosticRule.disableBytesTypePromotions, + DiagnosticRule.noImplicitReexport, ]; if (includeNonOverridable) { @@ -541,6 +546,7 @@ export function getOffDiagnosticRuleSet(): DiagnosticRuleSet { enableReachabilityAnalysis: false, deprecateTypingAliases: false, disableBytesTypePromotions: true, + noImplicitReexport: true, reportGeneralTypeIssues: 'none', reportPropertyTypeMismatch: 'none', reportFunctionMemberAccess: 'none', @@ -644,6 +650,7 @@ export function getBasicDiagnosticRuleSet(): DiagnosticRuleSet { enableReachabilityAnalysis: true, deprecateTypingAliases: false, disableBytesTypePromotions: true, + noImplicitReexport: true, reportGeneralTypeIssues: 'error', reportPropertyTypeMismatch: 'none', reportFunctionMemberAccess: 'none', @@ -747,6 +754,7 @@ export function getStandardDiagnosticRuleSet(): DiagnosticRuleSet { enableReachabilityAnalysis: true, deprecateTypingAliases: false, disableBytesTypePromotions: true, + noImplicitReexport: true, reportGeneralTypeIssues: 'error', reportPropertyTypeMismatch: 'none', reportFunctionMemberAccess: 'error', @@ -850,6 +858,7 @@ export function getStrictDiagnosticRuleSet(): DiagnosticRuleSet { enableReachabilityAnalysis: true, // Not overridden by strict mode deprecateTypingAliases: false, disableBytesTypePromotions: true, + noImplicitReexport: true, reportGeneralTypeIssues: 'error', reportPropertyTypeMismatch: 'none', reportFunctionMemberAccess: 'error', diff --git a/packages/pyright-internal/src/common/diagnosticRules.ts b/packages/pyright-internal/src/common/diagnosticRules.ts index 45cb51f6c156..4d736b31362d 100644 --- a/packages/pyright-internal/src/common/diagnosticRules.ts +++ b/packages/pyright-internal/src/common/diagnosticRules.ts @@ -21,6 +21,7 @@ export enum DiagnosticRule { enableReachabilityAnalysis = 'enableReachabilityAnalysis', deprecateTypingAliases = 'deprecateTypingAliases', disableBytesTypePromotions = 'disableBytesTypePromotions', + noImplicitReexport = 'noImplicitReexport', reportGeneralTypeIssues = 'reportGeneralTypeIssues', reportPropertyTypeMismatch = 'reportPropertyTypeMismatch', diff --git a/packages/vscode-pyright/schemas/pyrightconfig.schema.json b/packages/vscode-pyright/schemas/pyrightconfig.schema.json index cb6c591eb426..d50c5c95b64b 100644 --- a/packages/vscode-pyright/schemas/pyrightconfig.schema.json +++ b/packages/vscode-pyright/schemas/pyrightconfig.schema.json @@ -99,6 +99,11 @@ "title": "Treat typing-specific aliases to standard types as deprecated", "default": false }, + "noImplicitReexport": { + "type": "boolean", + "title": "Require explicit re-export of imported symbols in py.typed packages (mirrors mypy's no_implicit_reexport)", + "default": true + }, "reportGeneralTypeIssues": { "$ref": "#/definitions/diagnostic", "title": "Controls reporting of general type issues", @@ -618,6 +623,9 @@ "deprecateTypingAliases": { "$ref": "#/definitions/deprecateTypingAliases" }, + "noImplicitReexport": { + "$ref": "#/definitions/noImplicitReexport" + }, "reportGeneralTypeIssues": { "$ref": "#/definitions/reportGeneralTypeIssues" }, @@ -936,6 +944,9 @@ "deprecateTypingAliases": { "$ref": "#/definitions/deprecateTypingAliases" }, + "noImplicitReexport": { + "$ref": "#/definitions/noImplicitReexport" + }, "reportGeneralTypeIssues": { "$ref": "#/definitions/reportGeneralTypeIssues" }, From f1075f041fa3e30a5e3bebf663f0adcd717e749d Mon Sep 17 00:00:00 2001 From: Greg Friedman Date: Fri, 20 Feb 2026 11:20:59 -0500 Subject: [PATCH 2/4] Add failing test for noImplicitReexport=false behavior Adds two unit tests to the noImplicitReexport describe block: - A test.failing() asserting that public names should not produce reportPrivateImportUsage errors when noImplicitReexport=false - A passing test confirming the default (noImplicitReexport=true) still errors on both public and private implicit imports The test.failing() will be un-failed in the next commit when the behavior is implemented in typeEvaluator.ts. --- .../src/tests/privateImportUsage.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/pyright-internal/src/tests/privateImportUsage.test.ts b/packages/pyright-internal/src/tests/privateImportUsage.test.ts index bb63f59e6e83..878e362214e0 100644 --- a/packages/pyright-internal/src/tests/privateImportUsage.test.ts +++ b/packages/pyright-internal/src/tests/privateImportUsage.test.ts @@ -167,3 +167,105 @@ describe('reportPrivateImportUsage with tracked library files', () => { sp.dispose(); }); }); + +describe('noImplicitReexport config option', () => { + // pkg_a: py.typed library that re-exports PublicClass via plain import (no __all__, no `as` alias) + // pkg_b: consumer that imports PublicClass from pkg_a + const files = [ + { + path: combinePaths(libraryRoot, 'pkg_a', '__init__.py'), + content: 'from ._impl import PublicClass\n_PrivateClass = object', + }, + { + path: combinePaths(libraryRoot, 'pkg_a', 'py.typed'), + content: '', + }, + { + path: combinePaths(libraryRoot, 'pkg_a', '_impl.py'), + content: 'class PublicClass: pass\nclass _PrivateClass: pass', + }, + { + path: normalizeSlashes('/src/consumer.py'), + content: [ + 'from pkg_a import PublicClass', // public name — should be allowed with noImplicitReexport=false + 'from pkg_a import _PrivateClass', // private name — should always error + ].join('\n'), + }, + ]; + + test.failing('public name implicit re-export should not error when noImplicitReexport=false', () => { + const sp = createServiceProviderFromFiles(files); + const configOptions = new ConfigOptions(UriEx.file('/')); + configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'error'; + configOptions.diagnosticRuleSet.noImplicitReexport = false; + + const importResolver = new ImportResolver( + sp, + configOptions, + new TestAccessHost(sp.fs().getModulePath(), [UriEx.file(libraryRoot)]) + ); + const program = new Program(importResolver, configOptions, sp); + const consumerUri = UriEx.file('/src/consumer.py'); + program.setTrackedFiles([consumerUri]); + while (program.analyze()) { + // keep analyzing until complete + } + + const sourceFile = program.getSourceFile(consumerUri); + assert(sourceFile, 'Source file should exist'); + const diagnostics = sourceFile.getDiagnostics(configOptions) || []; + const errors = diagnostics.filter((d) => d.category === DiagnosticCategory.Error); + + // PublicClass should not error; _PrivateClass should still error (1 total) + assert.strictEqual( + errors.length, + 1, + `Expected 1 error (for _PrivateClass only), got ${errors.length}: ${errors + .map((e) => e.message) + .join(', ')}` + ); + assert( + errors[0].message.includes('_PrivateClass'), + `The sole error should be about _PrivateClass, got: ${errors[0].message}` + ); + + program.dispose(); + sp.dispose(); + }); + + test('public name implicit re-export still errors when noImplicitReexport=true (default)', () => { + const sp = createServiceProviderFromFiles(files); + const configOptions = new ConfigOptions(UriEx.file('/')); + configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'error'; + configOptions.diagnosticRuleSet.noImplicitReexport = true; + + const importResolver = new ImportResolver( + sp, + configOptions, + new TestAccessHost(sp.fs().getModulePath(), [UriEx.file(libraryRoot)]) + ); + const program = new Program(importResolver, configOptions, sp); + const consumerUri = UriEx.file('/src/consumer.py'); + program.setTrackedFiles([consumerUri]); + while (program.analyze()) { + // keep analyzing until complete + } + + const sourceFile = program.getSourceFile(consumerUri); + assert(sourceFile, 'Source file should exist'); + const diagnostics = sourceFile.getDiagnostics(configOptions) || []; + const errors = diagnostics.filter((d) => d.category === DiagnosticCategory.Error); + + // Both imports should error when noImplicitReexport=true + assert.strictEqual( + errors.length, + 2, + `Expected 2 errors (PublicClass and _PrivateClass), got ${errors.length}: ${errors + .map((e) => e.message) + .join(', ')}` + ); + + program.dispose(); + sp.dispose(); + }); +}); From 72afd1a4202ef72e261ecc2e24edcf9d429667de Mon Sep 17 00:00:00 2001 From: Greg Friedman Date: Fri, 20 Feb 2026 11:22:00 -0500 Subject: [PATCH 3/4] Implement noImplicitReexport option and add tests/docs When noImplicitReexport=false, reportPrivateImportUsage is suppressed for public names (no leading underscore) that are implicitly re-exported via plain `from X import Y` in py.typed packages, mirroring mypy's no_implicit_reexport=false behavior. Names with a leading underscore continue to be reported regardless of this setting. Changes: - typeEvaluator.ts: guard both reportPrivateImportUsage raise sites - privateImportUsage.test.ts: promote test.failing() -> test() - docs/configuration.md: document noImplicitReexport with table row - import.pytyped.noImplicitReexport.fourslash.ts: fourslash test covering public names (no error) and underscore-prefixed names (still errors) Closes #11288 --- docs/configuration.md | 3 + .../src/analyzer/typeEvaluator.ts | 55 +++++++++++-------- ...rt.pytyped.noImplicitReexport.fourslash.ts | 36 ++++++++++++ .../src/tests/privateImportUsage.test.ts | 2 +- 4 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 packages/pyright-internal/src/tests/fourslash/import.pytyped.noImplicitReexport.fourslash.ts diff --git a/docs/configuration.md b/docs/configuration.md index 299c1eb034d0..8eddba0411e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,6 +64,8 @@ The following settings determine how different types should be evaluated. - **disableBytesTypePromotions** [boolean]: Disables legacy behavior where `bytearray` and `memoryview` are considered subtypes of `bytes`. [PEP 688](https://peps.python.org/pep-0688/#no-special-meaning-for-bytes) deprecates this behavior, but this switch is provided to restore the older behavior. The default value for this setting is `true`. +- **noImplicitReexport** [boolean]: PEP 484 indicates that imported symbols in `py.typed` packages are not considered re-exported unless they appear in `__all__` or use the redundant alias form (`from X import Y as Y`). When this setting is `false`, plain `from X import Y` imports are treated as valid re-exports for public names (those without a leading underscore), mirroring mypy's `no_implicit_reexport = false` behavior. Names with a leading underscore are always treated as private regardless of this setting. The default value for this setting is `true`. + ## Type Check Diagnostics Settings The following settings control pyright’s diagnostic output (warnings or errors). @@ -351,6 +353,7 @@ The following table lists the default severity levels for each diagnostic rule w | :---------------------------------------- | :--------- | :--------- | :--------- | :--------- | | analyzeUnannotatedFunctions | true | true | true | true | | disableBytesTypePromotions | true | true | true | true | +| noImplicitReexport | true | true | true | true | | strictParameterNoneValue | true | true | true | true | | enableTypeIgnoreComments | true | true | true | true | | enableReachabilityAnalysis | false | true | true | true | diff --git a/packages/pyright-internal/src/analyzer/typeEvaluator.ts b/packages/pyright-internal/src/analyzer/typeEvaluator.ts index 6cd8a1b8d814..f6f83caf9d52 100644 --- a/packages/pyright-internal/src/analyzer/typeEvaluator.ts +++ b/packages/pyright-internal/src/analyzer/typeEvaluator.ts @@ -5985,14 +5985,19 @@ export function createTypeEvaluator( } if (symbol.isPrivatePyTypedImport()) { - addDiagnostic( - DiagnosticRule.reportPrivateImportUsage, - LocMessage.privateImportFromPyTypedModule().format({ - name: memberName, - module: baseType.priv.moduleName, - }), - node.d.member - ); + const fileInfo = AnalyzerNodeInfo.getFileInfo(node); + const isImplicitReexportAllowed = + !fileInfo.diagnosticRuleSet.noImplicitReexport && !memberName.startsWith('_'); + if (!isImplicitReexportAllowed) { + addDiagnostic( + DiagnosticRule.reportPrivateImportUsage, + LocMessage.privateImportFromPyTypedModule().format({ + name: memberName, + module: baseType.priv.moduleName, + }), + node.d.member + ); + } } } else { // Does the module export a top-level __getattr__ function? @@ -20499,22 +20504,28 @@ export function createTypeEvaluator( } if (resolvedAliasInfo.privatePyTypedImporter) { - const diag = new DiagnosticAddendum(); - if (resolvedAliasInfo.privatePyTypedImported) { - diag.addMessage( - LocAddendum.privateImportFromPyTypedSource().format({ - module: resolvedAliasInfo.privatePyTypedImported, - }) + const importedName = node.d.name.d.value; + const fileInfo = AnalyzerNodeInfo.getFileInfo(node); + const isImplicitReexportAllowed = + !fileInfo.diagnosticRuleSet.noImplicitReexport && !importedName.startsWith('_'); + if (!isImplicitReexportAllowed) { + const diag = new DiagnosticAddendum(); + if (resolvedAliasInfo.privatePyTypedImported) { + diag.addMessage( + LocAddendum.privateImportFromPyTypedSource().format({ + module: resolvedAliasInfo.privatePyTypedImported, + }) + ); + } + addDiagnostic( + DiagnosticRule.reportPrivateImportUsage, + LocMessage.privateImportFromPyTypedModule().format({ + name: importedName, + module: resolvedAliasInfo.privatePyTypedImporter, + }) + diag.getString(), + node.d.name ); } - addDiagnostic( - DiagnosticRule.reportPrivateImportUsage, - LocMessage.privateImportFromPyTypedModule().format({ - name: node.d.name.d.value, - module: resolvedAliasInfo.privatePyTypedImporter, - }) + diag.getString(), - node.d.name - ); } } diff --git a/packages/pyright-internal/src/tests/fourslash/import.pytyped.noImplicitReexport.fourslash.ts b/packages/pyright-internal/src/tests/fourslash/import.pytyped.noImplicitReexport.fourslash.ts new file mode 100644 index 000000000000..ee23e37ab9aa --- /dev/null +++ b/packages/pyright-internal/src/tests/fourslash/import.pytyped.noImplicitReexport.fourslash.ts @@ -0,0 +1,36 @@ +/// + +// @filename: pyrightconfig.json +//// { +//// "typeCheckingMode": "basic", +//// "noImplicitReexport": false +//// } + +// @filename: testLib/py.typed +// @library: true +//// + +// @filename: testLib/__init__.py +// @library: true +//// from .module1 import one as one, two, three + +// @filename: testLib/module1.py +// @library: true +//// one: int = 1 +//// two: int = 2 +//// three: int = 3 + +// @filename: .src/test1.py +//// # pyright: reportPrivateImportUsage=true +//// from testLib import one # explicit re-export (as-alias) — always ok +//// from testLib import two # plain import, public name — ok with noImplicitReexport=false +//// from testLib import three # plain import, public name — ok with noImplicitReexport=false +//// import testLib +//// testLib.one +//// testLib.two # ok with noImplicitReexport=false +//// testLib.three # ok with noImplicitReexport=false + +// Verify that no reportPrivateImportUsage errors are raised for public names +// when noImplicitReexport=false. Private-name behavior is tested in privateImportUsage.test.ts. +// @ts-ignore +await helper.verifyDiagnostics(); diff --git a/packages/pyright-internal/src/tests/privateImportUsage.test.ts b/packages/pyright-internal/src/tests/privateImportUsage.test.ts index 878e362214e0..4f689db741d6 100644 --- a/packages/pyright-internal/src/tests/privateImportUsage.test.ts +++ b/packages/pyright-internal/src/tests/privateImportUsage.test.ts @@ -193,7 +193,7 @@ describe('noImplicitReexport config option', () => { }, ]; - test.failing('public name implicit re-export should not error when noImplicitReexport=false', () => { + test('public name implicit re-export should not error when noImplicitReexport=false', () => { const sp = createServiceProviderFromFiles(files); const configOptions = new ConfigOptions(UriEx.file('/')); configOptions.diagnosticRuleSet.reportPrivateImportUsage = 'error'; From bb1dd82c04b8c2e6817d3f27af55f8eec12560b1 Mon Sep 17 00:00:00 2001 From: Greg Friedman Date: Fri, 20 Feb 2026 12:20:51 -0500 Subject: [PATCH 4/4] Add python.analysis.noImplicitReexport to vscode-pyright package.json Addresses review feedback requesting a VS Code setting entry for the new noImplicitReexport config option. --- packages/vscode-pyright/package.json | 6 ++++++ packages/vscode-pyright/schemas/pyrightconfig.schema.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/vscode-pyright/package.json b/packages/vscode-pyright/package.json index ad63927f9820..82979183bcec 100644 --- a/packages/vscode-pyright/package.json +++ b/packages/vscode-pyright/package.json @@ -1500,6 +1500,12 @@ "Trace" ] }, + "python.analysis.noImplicitReexport": { + "type": "boolean", + "default": true, + "description": "When true, imported symbols in py.typed packages require explicit re-export (via __all__ or 'as' alias). Set to false to allow plain imports as re-exports for public names.", + "scope": "resource" + }, "python.analysis.typeCheckingMode": { "type": "string", "default": "standard", diff --git a/packages/vscode-pyright/schemas/pyrightconfig.schema.json b/packages/vscode-pyright/schemas/pyrightconfig.schema.json index d50c5c95b64b..9f6e35779f7f 100644 --- a/packages/vscode-pyright/schemas/pyrightconfig.schema.json +++ b/packages/vscode-pyright/schemas/pyrightconfig.schema.json @@ -101,7 +101,7 @@ }, "noImplicitReexport": { "type": "boolean", - "title": "Require explicit re-export of imported symbols in py.typed packages (mirrors mypy's no_implicit_reexport)", + "title": "When true, imported symbols in py.typed packages require explicit re-export (via __all__ or 'as' alias). Set to false to allow plain imports as re-exports for public names.", "default": true }, "reportGeneralTypeIssues": {