diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 0d5c54857..71d448e58 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -43,6 +43,12 @@ export interface ProjectModuleFacts extends ResolutionInput { * re-export fails to resolve. */ isPackage?: boolean; + /** + * Whether this module is a `.pyi` stub. A stub shipped beside its `.py` carries the + * same qualified name, and an import must reach the `.py`: that is the module the + * interpreter loads. A stub with no `.py` beside it stays the import's target. + */ + isStub?: boolean; } export interface ProjectResolutionStats { @@ -189,6 +195,8 @@ export class PythonResolutionLinker { const moduleByQualifiedName = new Map(); for (const module of modules) { + const seen = moduleByQualifiedName.get(module.qualifiedName); + if (seen !== undefined && module.isStub && !seen.isStub) continue; moduleByQualifiedName.set(module.qualifiedName, module); } diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 0c07497a5..0ec56d2bf 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -320,6 +320,7 @@ export class PythonProjectAnalyzer { qualifiedName: facts.module.getQualifiedName(), moduleHash: facts.module.getHash(), isPackage: isPythonPackageInitFileName(path.basename(filePath)), + isStub: filePath.endsWith('.pyi'), scopes: facts.scopes, bindings: facts.bindings, types: facts.types, diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/case.json b/tests/cases/python/stub-beside-source-resolves-to-source/case.json new file mode 100644 index 000000000..97a74a7fb --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/case.json @@ -0,0 +1,8 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a package shipping .pyi stubs beside its .py modules: a call that resolves to the stub's declaration reaches the .py implementation of the same qualified name, the one CPython runs", + "run": ["impact", "pkg/impl.py:4", "--tests"], + "want": ["test_chunk.py"]}, + {"why": "CONTROL: the stub itself is never an edge target", + "run": ["impact", "pkg/impl.pyi:1", "--tests"], + "avoid": ["[resolved] test_chunk"]}]} diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py new file mode 100644 index 000000000..66c63213a --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.py @@ -0,0 +1 @@ +from .impl import * # noqa diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi new file mode 100644 index 000000000..475cf4eac --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/__init__.pyi @@ -0,0 +1 @@ +from .impl import * diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py new file mode 100644 index 000000000..79628415b --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.py @@ -0,0 +1,5 @@ +__all__ = ["chunk"] + + +def chunk(xs, n): + return [xs[i:i + n] for i in range(0, len(xs), n)] diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi new file mode 100644 index 000000000..3f70ad893 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pkg/impl.pyi @@ -0,0 +1 @@ +def chunk(xs: list, n: int) -> list: ... diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml new file mode 100644 index 000000000..c311e64bc --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "pkg" +version = "0" diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py new file mode 100644 index 000000000..c459b14b6 --- /dev/null +++ b/tests/cases/python/stub-beside-source-resolves-to-source/tests/test_chunk.py @@ -0,0 +1,5 @@ +import pkg as p + + +def test_chunk(): + assert p.chunk([1, 2, 3], 2) == [[1, 2], [3]]