1- from __future__ import annotations
2-
31import re
4- import ast
5- import dataclasses
6- from pathlib import Path
72from typing import Iterable
3+ from pathlib import Path
84
95from .vfs import VirtualFile , Vfs , F
10-
11-
12- __all__ = (
13- 'get_module_path' ,
14- 'EMPTY_TUPLE' ,
15- 'F' ,
16- 'SharedPaths' ,
17- 'NotExcludedBy' ,
18- 'VirtualFile' ,
19- 'Vfs'
20- )
21-
6+ from .import_resolver import build_import_tree
227
238EMPTY_TUPLE = tuple ()
24-
25-
26- class SharedPaths :
27- """These are often used to set up a Vfs and open files."""
28- REPO_UTILS_DIR = Path (__file__ ).parent .parent .resolve ()
29- REPO_ROOT = REPO_UTILS_DIR .parent
30- ARCADE_ROOT = REPO_ROOT / "arcade"
31- DOC_ROOT = REPO_ROOT / "doc"
32- API_DOC_ROOT = DOC_ROOT / "api_docs"
9+ _VALID_MODULE_SEGMENT = re .compile (r"[_a-zA-Z][_a-z0-9]*" )
3310
3411
3512class NotExcludedBy :
@@ -45,7 +22,14 @@ def __call__(self, item) -> bool:
4522 return item not in self .items
4623
4724
48- _VALID_MODULE_SEGMENT = re .compile (r"[_a-zA-Z][_a-z0-9]*" )
25+ class SharedPaths :
26+ """These are often used to set up a Vfs and open files."""
27+ REPO_UTILS_DIR = Path (__file__ ).parent .parent .resolve ()
28+ REPO_ROOT = REPO_UTILS_DIR .parent
29+ ARCADE_ROOT = REPO_ROOT / "arcade"
30+ DOC_ROOT = REPO_ROOT / "doc"
31+ API_DOC_ROOT = DOC_ROOT / "api_docs"
32+
4933
5034
5135def get_module_path (module : str , root = SharedPaths .REPO_ROOT ) -> Path :
@@ -90,127 +74,68 @@ def get_module_path(module: str, root = SharedPaths.REPO_ROOT) -> Path:
9074 f"{ module } " )
9175
9276 return current
77+ class SharedPaths :
78+ """These are often used to set up a Vfs and open files."""
79+ REPO_UTILS_DIR = Path (__file__ ).parent .parent .resolve ()
80+ REPO_ROOT = REPO_UTILS_DIR .parent
81+ ARCADE_ROOT = REPO_ROOT / "arcade"
82+ DOC_ROOT = REPO_ROOT / "doc"
83+ API_DOC_ROOT = DOC_ROOT / "api_docs"
9384
9485
95- # Tools for resolving the lowest import of a member in Arcade.
96- # Members are imported in various `__init__` files and we want
97- # present. arcade.Sprite instead of arcade.sprite.Sprite as an example.
98- # Build a tree using the ast module looking at the __init__ files
99- # and recurse the tree to find the lowest import of a member.
100-
101- @dataclasses .dataclass
102- class ImportNode :
103- """A node in the import tree."""
104- name : str
105- parent : ImportNode | None = None
106- children : list [ImportNode ] = dataclasses .field (default_factory = list )
107- imports : list [Import ] = dataclasses .field (default_factory = list )
108- level : int = 0
109-
110- def get_full_module_path (self ) -> str :
111- """Get the module path from the root to this node."""
112- if self .parent is None :
113- return self .name
114-
115- name = self .parent .get_full_module_path ()
116- if name :
117- return f"{ name } .{ self .name } "
118- return self .name
119-
120- def resolve (self , full_path : str ) -> str :
121- """Return the lowest import of a member in the tree."""
122- name = full_path .split ("." )[- 1 ]
123-
124- # Find an import in this module likely to be the one we want.
125- for imp in self .imports :
126- if imp .name == name and imp .from_module in full_path :
127- return f"{ imp .module } .{ imp .name } "
128-
129- # Move on to children
130- for child in self .children :
131- result = child .resolve (full_path )
132- if result :
133- return result
134-
135- # Return the full path if we can't find any relevant imports.
136- # It means the member is in a sub-module and are not importer anywhere.
137- return full_path
138-
139- def print_tree (self , depth = 0 ):
140- """Print the tree."""
141- print (" " * depth * 4 , "---" , self .name )
142- for imp in self .imports :
143- print (" " * (depth + 1 ) * 4 , f"-> { imp } " )
144- for child in self .children :
145- child .print_tree (depth + 1 )
146-
147-
148- @dataclasses .dataclass
149- class Import :
150- """Unified representation of an import statement."""
151- name : str # name of the member
152- module : str # The module this import is from
153- from_module : str # The module the member was imported from
154-
155-
156- def build_import_tree (root : Path ) -> ImportNode :
157- """
158- Build a tree of all the modules in a package.
86+
87+ def get_module_path (module : str , root = SharedPaths .REPO_ROOT ) -> Path :
88+ """Quick-n-dirty module path estimation relative to the repo root.
15989
16090 Args:
161- root: The root of the package to build the tree from.
91+ module: A module path in the project.
92+ Raises:
93+ ValueError: When a can't be computed.
16294 Returns:
163- The root node of the tree.
95+ An absolute file path to the module
16496 """
165- node = _parse_import_node_recursive (root , parent = None )
166- if node is None :
167- raise RuntimeError ("No __init__.py found in root" )
168- return node
97+ # Convert module.name.here to module/name/here
98+ current = root
99+ for index , part in enumerate (module .split ('.' )):
100+ if not _VALID_MODULE_SEGMENT .fullmatch (part ):
101+ raise ValueError (
102+ f'Invalid module segment at index { index } : { part !r} ' )
103+ # else:
104+ # print(current, part)
105+ current /= part
106+
107+ # Account for the two kinds of modules:
108+ # 1. arcade/module.py
109+ # 2. arcade/module/__init__.py
110+ as_package = current / "__init__.py"
111+ have_package = as_package .is_file ()
112+ as_file = current .with_suffix ('.py' )
113+ have_file = as_file .is_file ()
114+
115+ # TODO: When 3.10 becomes our min Python, make this a match-case?
116+ if have_package and have_file :
117+ raise ValueError (
118+ f"Module conflict between { as_package } and { as_file } " )
119+ elif have_package :
120+ current = as_package
121+ elif have_file :
122+ current = as_file
123+ else :
124+ raise ValueError (
125+ f"No folder package or file module detected for "
126+ f"{ module } " )
169127
128+ return current
170129
171- def _parse_import_node_recursive (
172- path : Path ,
173- parent : ImportNode | None = None ,
174- depth = 0 ,
175- ) -> ImportNode | None :
176- """Quickly gather import data using ast in a simplified/unified format.
177130
178- This is a recursive function that works itself down the directory tree
179- looking for __init__.py files and parsing them for imports.
180- """
181- _file = path / "__init__.py"
182- if not _file .exists ():
183- return None
184-
185- # Build the node
186- name = _file .parts [- 2 ]
187- node = ImportNode (name , parent = parent )
188- module = ast .parse (_file .read_text ())
189-
190- full_module_path = node .get_full_module_path ()
191-
192- for ast_node in ast .walk (module ):
193- if isinstance (ast_node , ast .Import ):
194- for alias in ast_node .names :
195- if not alias .name .startswith ("arcade." ):
196- continue
197- imp = Import (
198- name = alias .name .split ("." )[- 1 ],
199- module = full_module_path ,
200- from_module = "." .join (alias .name .split ("." )[:- 1 ])
201- )
202- node .imports .append (imp )
203- elif isinstance (ast_node , ast .ImportFrom ):
204- if ast_node .level == 0 and not ast_node .module .startswith ("arcade" ):
205- continue
206- for alias in ast_node .names :
207- imp = Import (alias .name , full_module_path , ast_node .module )
208- node .imports .append (imp )
209-
210- # Recurse subdirectories
211- for child_dir in path .iterdir ():
212- child = _parse_import_node_recursive (child_dir , parent = node , depth = depth + 1 )
213- if child :
214- node .children .append (child )
215-
216- return node
131+
132+ __all__ = (
133+ 'get_module_path' ,
134+ 'SharedPaths' ,
135+ 'EMPTY_TUPLE' ,
136+ 'F' ,
137+ 'NotExcludedBy' ,
138+ 'VirtualFile' ,
139+ 'Vfs' ,
140+ 'build_import_tree' ,
141+ )
0 commit comments