blob: f8d85bd0bfcd906cfd41236f99854d6331a89416 [file] [log] [blame]
#!/usr/bin/env python
# Copyright 2014 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""A simple python dependency checker.
Scans given python modules and see their dependency. Usage:
deps.py PYTHON_FILE(s)...
"""
from __future__ import print_function
import atexit
from distutils import sysconfig
import importlib
import os
import re
import sys
import traceback
import yaml
# Constants for config file.
CONFIG_GROUPS = r'groups'
CONFIG_RULES = r'rules'
CONFIG_GROUP_PATTERN = re.compile(r'^<([^<>].*)>$')
CONFIG_WILD_IMPORTS = r'*'
ENV_EXIT_VALUE = 'DEPS_EXIT_VALUE'
def GetDependencyList(path, base, exclude, include):
"""Gets dependency list of a given Python module.
Args:
path: A string for python module file (*.py).
base: A list of base modules that should not be returned.
exclude: A string of module path prefix to exclude.
include: A string of module path prefix to include (overrides exclude).
Returns:
A list of strings for files of module dependency.
"""
# Use the module's parent directory in case it uses relative imports.
# Works for the case of relative imports in that particular package:
# import .A
# from . import A
# But does not work for imports from the parent of the package:
# import ..B
# from .. import B
dir_path = os.path.dirname(os.path.dirname(path))
parent_dir_name = os.path.basename(os.path.dirname(path))
basename = os.path.join(parent_dir_name, os.path.basename(path))
module_name = basename.replace(os.path.sep, '.').rpartition('.py')[0]
sys.path.insert(0, dir_path)
target = importlib.import_module(module_name)
sys.path.pop(0)
new_names = [name for name in sys.modules if name not in base]
new_modules = [sys.modules[name] for name in new_names if sys.modules[name]]
new_modules.remove(target)
dependency = []
for module in new_modules:
if '__file__' not in module.__dict__:
# Assume this is a built-in module.
continue
module_path = module.__file__
if (module_path.startswith(exclude) and
not module_path.startswith(include)):
continue
key_module_ready = 'MODULE_READY'
if key_module_ready in module.__dict__:
# Special variable defined in py/external/_wrapper.py
if not module.__dict__[key_module_ready]:
print('Warning: sub-module not loaded: %s' % module_path)
dependency.append(module_path)
# Delete references to new modules so we can build correct dependency list for
# next file. Note this won't really unload modules.
for name in new_names:
del sys.modules[name]
return dependency
def CheckDependencyList(module, depends, rules, package_top, standard_lib,
site_packages):
"""Checks if given module and dependency complies to the rules.
Args:
module: A string for full path of reference module.
depends: A list of strings for files imported by module.
rules: A dictionary of {package: imports} that package is only allowed to
import from the "imports" list.
package_top: A string of path to the top level of package.
standard_lib: A string for Python standard library path.
site_packages: A string for Python site packages path.
Returns:
A list of strings for modules that should not be imported.
"""
def GetPackage(py_path, package_top):
"""Converts a Python file path into Python package name."""
if py_path.startswith(site_packages):
py_path = py_path.replace(site_packages + os.path.sep, '', 1)
py_path = (os.path.dirname(py_path) if os.path.dirname(py_path) else
os.path.splitext(py_path)[0])
elif py_path.startswith(standard_lib):
py_path = py_path.replace(standard_lib + os.path.sep, '', 1)
elif py_path.startswith(package_top):
# Note py_path may start as factory/py or factory/py_pkg/cros/factory.
py_path = py_path.replace(package_top, 'cros/factory', 1).replace(
'factory_pkg/cros/', '', 1)
else:
# Search by sys.path and use the best matched one.
path_list = [py_path.replace(prefix, '') for prefix in sys.path
if py_path.startswith(prefix)]
if path_list:
py_path = sorted(path_list)[-1]
py_path = (os.path.dirname(py_path) if os.path.dirname(py_path) else
os.path.splitext(py_path)[0])
return py_path.replace(os.path.sep, '.').strip('.')
def FindRule(package, rules):
"""Finds the best rule that matches given package."""
while '.' in package:
if package in rules:
return rules[package]
package = package.rpartition('.')[0]
raise Exception('Unknown package: %s' % package)
result = set()
package = GetPackage(module, package_top)
rule = FindRule(package, rules)
if CONFIG_WILD_IMPORTS in rule:
return list(result)
# Match modules
for path in depends:
if path.endswith('/factory_common.pyc'):
# factory_common is symlink everywhere and hard to check.
continue
package = GetPackage(path, package_top)
if package == 'cros' and path.endswith('__init__.pyc'):
# Allow only this implicitly loaded file in 'cros' package.
continue
if package not in rule:
result.add('%s (%s)' % (package, path))
return list(result)
def LoadConfiguration(path):
"""Loads dependency rules from a given (YAML) configuration file.
Args:
path: A string of file path to a YAML config file.
Returns:
A dictionary of {package: imports} describing "'package' can only import
from 'imports'".
"""
config = yaml.load(open(path))
if (CONFIG_GROUPS not in config) or (CONFIG_RULES not in config):
raise ValueError('Syntax error in %s' % path)
groups = config[CONFIG_GROUPS]
rules = {}
for key, value in config[CONFIG_RULES].items():
# Expand value into imports
imports = []
for package in value:
if re.match(CONFIG_GROUP_PATTERN, package):
imports += groups[re.findall(CONFIG_GROUP_PATTERN, package)[0]]
else:
imports.append(package)
if re.match(CONFIG_GROUP_PATTERN, key):
# Duplicate multiple rules
for module in groups[re.findall(CONFIG_GROUP_PATTERN, key)[0]]:
rules[module] = imports
else:
rules[key] = imports
return rules
def main(argv):
"""Main entry point for command line invocation.
Args:
argv: list of files to check dependency.
"""
base = sys.modules.copy()
standard_lib = sysconfig.get_python_lib(standard_lib=True)
site_packages = sysconfig.get_python_lib(standard_lib=False)
exit_value = int(os.getenv(ENV_EXIT_VALUE, '0'))
# Configuration file should be located in same folder.
# "cros.factory" should be mapped to parent folder of this program.
rules = LoadConfiguration(os.path.splitext(os.path.realpath(__file__))[0] +
'.conf')
package_top = os.path.abspath(os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'..'))
for argv_path in argv:
if not argv_path.endswith('.py'):
continue
if argv_path.endswith('_unittest.py'):
continue
# For symlink python files, we want to keep its path directory so abspath
# is better than realpath.
path = os.path.abspath(argv_path)
print('--- %s ---' % os.path.relpath(path))
try:
# Exclude Python Standard Library and include site packages.
deps = GetDependencyList(path, base, standard_lib, site_packages)
bad_imports = CheckDependencyList(path, deps, rules, package_top,
standard_lib, site_packages)
if bad_imports:
print('\n'.join(bad_imports))
exit_value = 1
except Exception as e:
# Import system may have been corrupted by packages with static
# registration like Zope or Twisted. Let's try again.
if argv.index(argv_path) > 0:
print('(cleaning import space for %s)' % argv_path)
os.putenv(ENV_EXIT_VALUE, str(exit_value))
os.execlp(sys.argv[0], sys.argv[0], *argv[argv.index(argv_path):])
tb = traceback.format_exc()
print('Failed checking %s: %s' % (path, e))
print(tb)
exit_value = 1
# Workaround modules that registered atexit hooks.
atexit._exithandlers = [] # pylint: disable=W0212
sys.exit(exit_value)
if __name__ == '__main__':
main(sys.argv[1:])