Skip to content

Commit 39c7fc2

Browse files
committed
Improved parsing with local namespace inspection
Code parsing has been completely refactored and improved to support the identification of corner import conditions that won't work in Pyscript. This includes unsupported packages, and local modules/packages import that won't work if not imported directly. As a result, ModuleFinder gathers all the packages via NamespaceInfo (populated via pkgutil) and collects results in a FinderResult object. This object will be later used to specialise the processing result.
1 parent 07ba09a commit 39c7fc2

File tree

1 file changed

+135
-38
lines changed

1 file changed

+135
-38
lines changed

src/pyscript/_node_parser.py

Lines changed: 135 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
Code adapted from the find-imports project, currently in graveyard archive.
44
"""
55
import ast
6+
from inspect import Attribute
7+
import os
8+
import pkgutil
69
from pathlib import Path
7-
from collections import namedtuple
8-
from threading import local
10+
from typing import Union
11+
from collections import namedtuple, defaultdict
12+
from itertools import filterfalse, chain
913

1014
from ._supported_packages import PACKAGE_RENAMES, STANDARD_LIBRARY, PYODIDE_PACKAGES
1115

@@ -14,58 +18,146 @@ class UnsupportedFileType(Exception):
1418
pass
1519

1620

17-
Environment = namedtuple("Environment", ["packages", "paths"])
21+
ImportInfo = namedtuple("ImportInfo", ["packages", "paths"])
22+
23+
24+
class NamespaceInfo:
25+
def __init__(self, source_fpath: Path) -> None:
26+
# expanding base_folder to absolute as pkgutils.FileFinder will do so - easier for later purging
27+
self.base_folder = str(source_fpath.parent.absolute())
28+
self.source_mod_name = source_fpath.stem
29+
self._collect()
30+
# storing this as it will be useful for multiple lookups
31+
self._all_namespace = set(chain(self.modules, self.packages))
32+
33+
def _collect(self):
34+
iter_modules_paths = [self.base_folder]
35+
for root, dirs, files in os.walk(self.base_folder):
36+
for dirname in dirs:
37+
iter_modules_paths.append(os.path.join(root, dirname))
38+
39+
# need to consume generator as I will iterate two times for packages, and modules
40+
pkg_mods = tuple(pkgutil.iter_modules(iter_modules_paths))
41+
modules = map(
42+
lambda mi: os.path.join(mi.module_finder.path, mi.name),
43+
filterfalse(
44+
lambda mi: mi.ispkg or mi.name == self.source_mod_name, pkg_mods
45+
),
46+
)
47+
packages = map(
48+
lambda mi: os.path.join(mi.module_finder.path, mi.name),
49+
filter(lambda mi: mi.ispkg, pkg_mods),
50+
)
51+
self.modules = set(map(self._dotted_path, modules))
52+
self.packages = set(map(self._dotted_path, packages))
53+
54+
def _dotted_path(self, p: str):
55+
p = p.replace(self.base_folder, "").replace(os.path.sep, ".")
56+
if p.startswith("."):
57+
p = p[1:]
58+
return p
59+
60+
def __contains__(self, item: str) -> bool:
61+
return item in self._all_namespace
62+
63+
def __str__(self) -> str:
64+
return f"NameSpace info for {self.base_folder} \n\t Modules: {self.modules} \n\t Packages: {self.packages}"
65+
66+
def __repr__(self) -> str:
67+
return str(self)
68+
69+
70+
class FinderResult:
71+
def __init__(self) -> None:
72+
self.packages = set()
73+
self.locals = set()
74+
self.unsupported = defaultdict(set)
75+
76+
def add_package(self, pkg_name: str) -> None:
77+
self.packages.add(pkg_name)
78+
79+
def add_locals(self, pkg_name: str) -> None:
80+
self.locals.add(pkg_name)
81+
82+
def add_unsupported_external_package(self, pkg_name: str) -> None:
83+
self.unsupported["external"].add(pkg_name)
84+
85+
def add_unsupported_local_package(self, pkg_name: str) -> None:
86+
self.unsupported["local"].add(pkg_name)
87+
88+
@property
89+
def has_warnings(self):
90+
return len(self.unsupported) > 0
91+
92+
@property
93+
def unsupported_packages(self):
94+
return self.unsupported["external"]
95+
96+
@property
97+
def unsupported_paths(self):
98+
return self.unsupported["local"]
99+
18100

19101
# https://stackoverflow.com/a/58847554
20102
class ModuleFinder(ast.NodeVisitor):
21-
def __init__(self, *args, **kwargs):
22-
self.packages = set()
23-
self.other_modules = set()
103+
def __init__(self, context: NamespaceInfo, *args, **kwargs):
104+
# list of all potential local imports
105+
self.context = context
106+
self.results = FinderResult()
24107
super().__init__(*args, **kwargs)
25108

26109
def visit_Import(self, node):
27110
for name in node.names:
28-
imported = name.name.split(".")[0]
29-
self._import_name(imported)
111+
# need to check for absolute module import here as they won't work in PyScript
112+
# absolute package imports will be found later in _import_name
113+
if len(name.name.split(".")) > 1 and name.name in self.context:
114+
self.results.add_unsupported_local_package(name.name)
115+
else:
116+
self._import_name(name.name)
30117

31118
def visit_ImportFrom(self, node):
32119
# if node.module is missing it's a "from . import ..." statement
33120
# if level > 0 it's a "from .submodule import ..." statement
34-
if node.module is not None and node.level == 0:
35-
imported = node.module.split(".")[0]
36-
self._import_name(imported)
121+
if node.module is not None:
122+
self._import_name(node.module)
37123

38124
def _import_name(self, imported):
39-
pkg_name = PACKAGE_RENAMES.get(imported, imported)
40-
if pkg_name not in STANDARD_LIBRARY:
41-
if pkg_name in PYODIDE_PACKAGES:
42-
self.packages.add(pkg_name)
125+
if imported in self.context:
126+
if imported not in self.context.packages:
127+
self.results.add_locals(imported)
43128
else:
44-
self.other_modules.add(pkg_name)
45-
46-
47-
def _find_modules(source: str, source_fpath: Path) -> Environment:
129+
self.results.add_unsupported_local_package(imported)
130+
else:
131+
imported = imported.split(".")[0]
132+
pkg_name = PACKAGE_RENAMES.get(imported, imported)
133+
if pkg_name not in STANDARD_LIBRARY:
134+
if pkg_name in PYODIDE_PACKAGES:
135+
self.results.add_package(pkg_name)
136+
else:
137+
self.results.add_unsupported_external_package(pkg_name)
138+
139+
140+
def _find_modules(source: str, source_fpath: Path):
48141
fname = source_fpath.name
142+
# importing all local modules from source_fpath
143+
namespace_info = NamespaceInfo(source_fpath=source_fpath)
49144
# passing mode='exec' just in case defaults will change in the future
50145
nodes = ast.parse(source, fname, mode="exec")
51146

52-
finder = ModuleFinder()
147+
finder = ModuleFinder(context=namespace_info)
53148
finder.visit(nodes)
54-
print("Found modules: ", finder.packages, finder.other_modules)
55-
source_basefolder = source_fpath.parent
56-
local_mods = set(
57-
map(
58-
lambda p: Path(*p.parts[1:]),
59-
filter(
60-
lambda d: d.stem in finder.other_modules
61-
and ((d.is_file() and d.suffix == ".py") or d.is_dir()),
62-
source_basefolder.iterdir(),
63-
),
64-
)
149+
report = finder.results
150+
pyenv_paths = map(
151+
lambda l: "{}.py".format(l.replace(".", os.path.sep)), report.locals
152+
)
153+
pyenv = ImportInfo(packages=report.packages, paths=set(pyenv_paths))
154+
if not report.has_warnings:
155+
return pyenv, None
156+
157+
warnings = ImportInfo(
158+
packages=report.unsupported_packages, paths=report.unsupported_paths
65159
)
66-
print("local modules", local_mods)
67-
print("external mods", finder.packages)
68-
return Environment(packages=finder.packages, paths=local_mods)
160+
return pyenv, warnings
69161

70162

71163
def _convert_notebook(source_fpath: Path) -> str:
@@ -77,9 +169,11 @@ def _convert_notebook(source_fpath: Path) -> str:
77169
return source
78170

79171

80-
def find_imports(source_fpath: Path) -> Environment:
172+
def find_imports(
173+
source_fpath: Path,
174+
) -> Union[ImportInfo, tuple[ImportInfo, ImportInfo]]:
81175
"""
82-
Parse the input source, and returns its dependencies, as organised in
176+
Parse the input source, and returns its dependencies, as organised in
83177
the sets of external packages, and local modules, respectively.
84178
Any modules or package with the same name found in the local
85179
@@ -90,8 +184,11 @@ def find_imports(source_fpath: Path) -> Environment:
90184
91185
Returns
92186
-------
93-
tuple[set[str], set[str]]
94-
Pair of external modules, and local modules
187+
Union[ImportInfo, tuple[ImportInfo, ImportInfo]]
188+
The function returns an instance of `ImportInfo` containing the
189+
environment with packages and paths to include in py-env.
190+
Optionally, if the parsing detected unsupported packages and local modules,
191+
this will be returned as well (still as `ImportInfo` instance)
95192
"""
96193
fname, extension = source_fpath.name, source_fpath.suffix
97194
if extension == ".py":

0 commit comments

Comments
 (0)