diff options
author | William Deegan <bill@baddogconsulting.com> | 2021-04-19 02:34:19 (GMT) |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-04-19 02:34:19 (GMT) |
commit | ca884a6fda9ddc7f11affca8b8cc292e8026eba2 (patch) | |
tree | 6aa527c37ba66c7dd4526f06b75ee51d796fa172 /SCons/Scanner | |
parent | 0b1a9746194554f3578d3f9b7e4d9ad0cd5a0324 (diff) | |
parent | 93f8c808e1ffdec5171a60a985e1189e96baaddc (diff) | |
download | SCons-ca884a6fda9ddc7f11affca8b8cc292e8026eba2.zip SCons-ca884a6fda9ddc7f11affca8b8cc292e8026eba2.tar.gz SCons-ca884a6fda9ddc7f11affca8b8cc292e8026eba2.tar.bz2 |
Merge pull request #3825 from grossag/topic/grossag/pythondynamicscan
Teach the Python scanner to find generated files and directories
Diffstat (limited to 'SCons/Scanner')
-rw-r--r-- | SCons/Scanner/Python.py | 121 | ||||
-rw-r--r-- | SCons/Scanner/PythonTests.py | 72 |
2 files changed, 150 insertions, 43 deletions
diff --git a/SCons/Scanner/Python.py b/SCons/Scanner/Python.py index dc6812c..86aa001 100644 --- a/SCons/Scanner/Python.py +++ b/SCons/Scanner/Python.py @@ -82,6 +82,40 @@ def find_include_names(node): return all_matches +def find_import(import_path, search_paths): + """ + Finds the specified import in the various search paths. + For an import of "p", it could either result in a file named p.py or + p/__init__.py. We can't do two consecutive searches for p then p.py + because the first search could return a result that is lower in the + search_paths precedence order. As a result, it is safest to iterate over + search_paths and check whether p or p.py exists in each path. This allows + us to cleanly respect the precedence order. + + If the import is found, returns a tuple containing: + 1. Discovered dependency node (e.g. p/__init__.py or p.py) + 2. True if the import was a package, False if the import was a module. + 3. The Dir node in search_paths that the import is relative to. + If the import is not found, returns a tuple containing (None, False, None). + Callers should check for failure by checking whether the first entry in the + tuple is not None. + """ + for search_path in search_paths: + paths = [search_path] + # Note: if the same import is present as a package and a module, Python + # prefers the package. As a result, we always look for x/__init__.py + # before looking for x.py. + node = SCons.Node.FS.find_file(import_path + '/__init__.py', paths) + if node: + return node, True, search_path + else: + node = SCons.Node.FS.find_file(import_path + '.py', paths) + if node: + return node, False, search_path + + return None, False, None + + def scan(node, env, path=()): # cache the includes list in node so we only scan it once: if node.includes is not None: @@ -92,10 +126,10 @@ def scan(node, env, path=()): # if the same header is included many times. node.includes = list(map(SCons.Util.silent_intern, includes)) - # XXX TODO: Sort? nodes = [] if callable(path): path = path() + for module, imports in includes: is_relative = module.startswith('.') if is_relative: @@ -108,48 +142,57 @@ def scan(node, env, path=()): for i in itertools.repeat(None, num_parents): current_dir = current_dir.up() - search_paths = [current_dir.abspath] + search_paths = [current_dir] search_string = module_lstripped else: - search_paths = path + search_paths = [env.Dir(p) for p in path] search_string = module - module_components = search_string.split('.') - for search_path in search_paths: - candidate_path = os.path.join(search_path, *module_components) - # The import stored in "module" could refer to a directory or file. - import_dirs = [] - if os.path.isdir(candidate_path): - import_dirs = module_components - - # Because this resolved to a directory, there is a chance that - # additional imports (e.g. from module import A, B) could refer - # to files to import. - if imports: - for imp in imports: - file = os.path.join(candidate_path, imp + '.py') - if os.path.isfile(file): - nodes.append(file) - elif os.path.isfile(candidate_path + '.py'): - nodes.append(candidate_path + '.py') - import_dirs = module_components[:-1] - - # We can ignore imports because this resolved to a file. Any - # additional imports (e.g. from module.file import A, B) would - # only refer to functions in this file. - - # Take a dependency on all __init__.py files from all imported - # packages unless it's a relative import. If it's a relative - # import, we don't need to take the dependency because Python - # requires that all referenced packages have already been imported, - # which means that the dependency has already been established. - if import_dirs and not is_relative: - for i in range(len(import_dirs)): - init_components = module_components[:i+1] + ['__init__.py'] - init_path = os.path.join(search_path, *(init_components)) - if os.path.isfile(init_path): - nodes.append(init_path) - break + # If there are no paths, there is no point in parsing includes for this + # iteration of the loop. + if not search_paths: + continue + + module_components = [x for x in search_string.split('.') if x] + package_dir = None + hit_dir = None + if not module_components: + # This is just a "from . import x". + package_dir = search_paths[0] + else: + # Translate something like "import x.y" to a call to find_import + # with 'x/y' as the path. find_import will then determine whether + # we can find 'x/y/__init__.py' or 'x/y.py'. + import_node, is_dir, hit_dir = find_import( + '/'.join(module_components), search_paths) + if import_node: + nodes.append(import_node) + if is_dir: + package_dir = import_node.dir + + # If the statement was something like "from x import y, z", whether we + # iterate over imports depends on whether x was a package or module. + # If it was a module, y and z are just functions so we don't need to + # search for them. If it was a package, y and z are either packages or + # modules and we do need to search for them. + if package_dir and imports: + for i in imports: + import_node, _, _ = find_import(i, [package_dir]) + if import_node: + nodes.append(import_node) + + # Take a dependency on all __init__.py files from all imported + # packages unless it's a relative import. If it's a relative + # import, we don't need to take the dependency because Python + # requires that all referenced packages have already been imported, + # which means that the dependency has already been established. + if hit_dir and not is_relative: + import_dirs = module_components + for i in range(len(import_dirs)): + init_path = '/'.join(import_dirs[:i+1] + ['__init__.py']) + init_node = SCons.Node.FS.find_file(init_path, [hit_dir]) + if init_node and init_node not in nodes: + nodes.append(init_node) return sorted(nodes) diff --git a/SCons/Scanner/PythonTests.py b/SCons/Scanner/PythonTests.py index faf548a..84acd0c 100644 --- a/SCons/Scanner/PythonTests.py +++ b/SCons/Scanner/PythonTests.py @@ -21,6 +21,21 @@ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +""" +Unit tests for the Python scanner. These tests validate proper working of the +Python scanner by confirming that the results of the scan match expectations. + +The absolute path tests have strongly-defined behavior in that there is no real +ambiguity to what they should result in. For example, if you import package x, +you expect to get x/__init__.py as a dependency. + +The relative path tests that reach into ancestor directories do have some +ambiguity in whether to depend upon __init__.py in those referenced ancestor +directories. Python only allows these kinds of relative imports if the file is +part of a package, in which case those ancestor directories' __init__.py files +have already been imported. +""" + import SCons.compat import collections @@ -226,9 +241,6 @@ class PythonScannerTestImportsGrandparentModule(unittest.TestCase): 'nested1/nested2/nested3/imports_grandparent_module.py') path = s.path(env, source=[node]) deps = s(node, env, path) - # Note: there is some ambiguity here in what the scanner should return. - # Relative imports require that the referenced packages have already - # been imported. files = ['nested1/module.py'] deps_match(self, deps, files) @@ -253,7 +265,59 @@ class PythonScannerTestImportsParentThenSubmodule(unittest.TestCase): 'nested1/nested2/nested3/imports_parent_then_submodule.py') path = s.path(env, source=[node]) deps = s(node, env, path) - files = ['nested1/nested2a/module.py'] + files = ['nested1/nested2a/__init__.py', 'nested1/nested2a/module.py'] + deps_match(self, deps, files) + + +class PythonScannerTestImportsModuleWithFunc(unittest.TestCase): + def runTest(self): + """ + This test case tests the following import statement: + `from simple_package.module1 import somefunc` with somefunc.py existing + in the same folder as module1.py. It validates that the scanner doesn't + accidentally take a dependency somefunc.py. + """ + env = DummyEnvironment() + s = SCons.Scanner.Python.PythonScanner + env['ENV']['PYTHONPATH'] = test.workpath('') + deps = s(env.File('from_import_simple_package_module1_func.py'), env, + lambda : s.path(env)) + files = ['simple_package/__init__.py', 'simple_package/module1.py'] + deps_match(self, deps, files) + + +class PythonScannerTestFromNested1ImportNested2(unittest.TestCase): + def runTest(self): + """ + This test case tests the following import statement: + `from nested1 import module, nested2`. In this test, module is a Python + module and nested2 is a package. Validates that the scanner can handle + such mixed imports. + """ + env = DummyEnvironment() + s = SCons.Scanner.Python.PythonScanner + env['ENV']['PYTHONPATH'] = test.workpath('') + deps = s(env.File('from_nested1_import_multiple.py'), env, + lambda : s.path(env)) + files = ['nested1/__init__.py', 'nested1/module.py', + 'nested1/nested2/__init__.py'] + deps_match(self, deps, files) + + +class PythonScannerTestImportUnknownFiles(unittest.TestCase): + def runTest(self): + """ + This test case tests importing files that are not found. If Python + really can't find those files, it will fail. But this is intended to + test the various failure paths in the scanner to make sure that they + don't raise exceptions. + """ + env = DummyEnvironment() + s = SCons.Scanner.Python.PythonScanner + env['ENV']['PYTHONPATH'] = test.workpath('') + deps = s(env.File('imports_unknown_files.py'), env, + lambda : s.path(env)) + files = [] deps_match(self, deps, files) |