6
\$\begingroup\$

I wrote a script to list imports of Python projects. It can print a JSON-ish dict of imported modules (keys) and members (values) as well as only the module names. It can also optionally exclude certain imports and those from the standard library.

#! /usr/bin/env python3
#  lsimports.py - List python project imports
#  Copyright (C) 2020 Richard Neumann <mail at richard dash neumann period de>
#
#  This program is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""Lists imports of python modules and packages."""

from argparse import ArgumentParser, Namespace
from collections import defaultdict
from itertools import chain
from json import dumps
from logging import INFO, WARNING, basicConfig, getLogger
from pathlib import Path
from re import fullmatch
from typing import Iterable, Iterator, Set, Tuple


DESCRIPTION = __doc__
FROM_IMPORT = 'from ([\\w\\.]+) import (.*)'
IMPORT = 'import (.*)'
LOG_FORMAT = '[%(levelname)s] %(name)s: %(message)s'
LOGGER = getLogger(Path(__file__).stem)
STDLIB = Path('/usr/lib/python3.9')


def get_imports(filename: Path) -> dict:
    """Lists imports of the given file."""

    imports = defaultdict(set)

    with filename.open('r') as file:
        for line in file:
            if not (line := line.strip()):
                continue

            if (match := fullmatch(FROM_IMPORT, line)) is not None:
                module, members = match.groups()
                members = set(filter(None, members.split(',')))
            elif (match := fullmatch(IMPORT, line)) is not None:
                module = match.group(1)
                members = set()
            else:
                continue

            imports[module] |= members

    return imports


def get_imports_from_files(paths: Iterable[Path]) -> dict:
    """Returns the imports from multiple files."""

    imports = {}

    for path in paths:
        LOGGER.info('Checking: %s', path)
        imports.update(get_imports(path))

    return imports


def get_module_roots(imports: dict) -> Set[str]:
    """Returns a set of imported module roots."""

    roots = set()

    for module in imports.keys():
        root, *_ = module.split('.')
        roots.add(root)

    return roots


def lsstdlib(root: Path) -> Iterator[str]:
    """Yields modules and packages of the standard library."""

    for path in root.iterdir():
        if path.is_file() and path.suffix == '.py' or path.is_dir():
            yield path.stem


def iterfiles(path: Path) -> Iterator[Path]:
    """Recursively yields files in a directory."""

    if path.is_dir():
        for subdir in path.iterdir():
            yield from iterfiles(subdir)
    elif path.is_file():
        yield path


def filterimports(excludes: set, imports: dict) -> Iterator[Tuple[str, set]]:
    """Yields filtered key/value pairs of imports."""

    for module, members in imports.items():
        root, *_ = module.split('.')

        if root not in excludes:
            yield (module, members)


def ispyfile(path: Path) -> bool:
    """Checks whether the file is a python file."""

    return path.is_file() and path.suffix == '.py'


def jsonify(imports: dict) -> dict:
    """Makes a dict JSON-compliant."""

    return {str(key): list(value) for key, value in imports.items()}


def get_args() -> Namespace:
    """Parses and returns the command line arguments."""

    parser = ArgumentParser(description=DESCRIPTION)
    parser.add_argument('path', nargs='*', type=Path, default=[Path.cwd()],
                        help='the files and folders to scan for imports')
    parser.add_argument('-E', '--exclude-stdlib', action='store_true',
                        help='exclude imports from the standard library')
    parser.add_argument('-e', '--exclude-modules', nargs='+', default=(),
                        help='exclude the specified imports')
    parser.add_argument('-i', '--indent', type=int, metavar='spaces',
                        help='set indentation for JSON output')
    parser.add_argument('-r', '--roots', action='store_true',
                        help='print only distinct root modules and packages')
    parser.add_argument('-s', '--stdlib', type=Path, default=STDLIB,
                        help='specifies the root of the standard library')
    parser.add_argument('-v', '--verbose', action='store_true',
                        help='print verbose messages')
    return parser.parse_args()


def main():
    """Runs the script."""

    args = get_args()
    basicConfig(format=LOG_FORMAT, level=INFO if args.verbose else WARNING)
    files = filter(ispyfile, chain(*map(iterfiles, args.path)))
    imports = get_imports_from_files(files)
    excludes = set(args.exclude_modules)

    if args.exclude_stdlib:
        excludes |= set(lsstdlib(args.stdlib))

    if excludes:
        imports = dict(filterimports(excludes, imports))

    if args.roots:
        for root in get_module_roots(imports):
            print(root)
    else:
        print(dumps(jsonify(imports), indent=args.indent))


if __name__ == '__main__':
    main()

How can I improve my code?

Update Gist: https://gist.github.com/conqp/cb41511869bba0d7c526e05df431aaf3

\$\endgroup\$
4
  • 1
    \$\begingroup\$ This will miss dynamic imports, but that could be quite hard to detect. Or it may be impossible to detect if it executes code that's external or loaded or generated from non-Python files or other sources, although such imports may or may not be considered an import of the project itself. \$\endgroup\$ Commented Dec 25, 2020 at 1:17
  • \$\begingroup\$ I think updated version fails on relative imports(import .common # common from same dir). It shows "Cannot parse "test.py" due to syntax error." error. \$\endgroup\$
    – Vashu
    Commented Dec 27, 2020 at 23:00
  • \$\begingroup\$ @Vashu This error message is logged when ast.parse() throws a SyntaxError here: gist.github.com/conqp/…. There's little I can do about that, if ast thinks that the code is syntactically incorrect. \$\endgroup\$ Commented Dec 28, 2020 at 9:20
  • \$\begingroup\$ @Vashu Thanks for the hint with relative imports, though. I personally never use them, but I updated the script to exclude relative imports now by checking the node level. \$\endgroup\$ Commented Dec 28, 2020 at 9:40

3 Answers 3

13
\$\begingroup\$

The biggest issue with this code is that it attempts manual parsing of Python on its own. This is neither the simplest approach nor the most robust. Instead, strongly consider calling into ast:

https://docs.python.org/3.8/library/ast.html#ast-helpers

which will tell you details about any import statements in code you feed to it. You'll be interested in these:

          | Import(alias* names)
          | ImportFrom(identifier? module, alias* names, int? level)
\$\endgroup\$
1
  • 2
    \$\begingroup\$ Thank you for the suggestion of ast. I never worked with it, but it turned out to be pretty easy. I refactored the script from the regex approach to using a parsed AST. \$\endgroup\$ Commented Dec 24, 2020 at 17:01
6
\$\begingroup\$

Bug?

You should probably strip() your values, because I get spurious spaces when I import multiple things from a module:

from x import y, z
-> {"x": ["y", " z"]}

Maintainability

You are currently hard-coding the python version for the built-ins and assume that it can be found in the standard UNIX path. Both of those assumptions might be violated. For the former problem, you could just read the python version:

import sys

version = sys.version_info

STDLIB = Path(f"/usr/lib/python{version.major}.{version.minor}")

But it's probably easier to directly use sys.builtin_module_names:

('_abc', '_ast', '_bisect', '_blake2', '_codecs', '_collections', '_csv', '_datetime', '_elementtree', '_functools', '_heapq', '_imp', '_io', '_locale', '_md5', '_operator', '_pickle', '_posixsubprocess', '_random', '_sha1', '_sha256', '_sha3', '_sha512', '_signal', '_socket', '_sre', '_stat', '_statistics', '_string', '_struct', '_symtable', '_thread', '_tracemalloc', '_warnings', '_weakref', 'array', 'atexit', 'binascii', 'builtins', 'cmath', 'errno', 'faulthandler', 'fcntl', 'gc', 'grp', 'itertools', 'marshal', 'math', 'posix', 'pwd', 'pyexpat', 'select', 'spwd', 'sys', 'syslog', 'time', 'unicodedata', 'xxsubtype', 'zlib')

Naming

lsstdlib is not a very good name. At least use the python convention (made official in PEP8) of using underscores and call it ls_std_lib.

Other

For your iterfiles function, I would use os.walk, even though that is not one of the fancy new Path methods, because it does the recursive descend into sub-directories for you:

import os

def iterfiles(path: Path) -> Iterator[Path]:
    """Recursively yields files in a directory."""
    return (Path(os.path.join(dirname, file))
            for dirname, _, files in os.walk(path)
            for file in files)
\$\endgroup\$
3
  • 1
    \$\begingroup\$ Thanks for the review. I addressed the problem of the stdlib with from distutils.sysconfig import get_python_lib and STDLIB = Path(get_python_lib(standard_lib=True)). As for the spaces: Yup, that was a bug, that I did not see. I added stripping: members = set(filter(None, map(str.strip, members.split(',')))) and also updated the regexes to exclude possible comments: FROM_IMPORT = '^\\s*from ([\\w\\.]+) import ([^#]*)(?:#.*)?$' IMPORT = '^\\s*import ([^#]*)(?:#.*)?$' \$\endgroup\$ Commented Dec 24, 2020 at 16:17
  • \$\begingroup\$ builtin_module_names lists C extension modules, not all standard library ones - collections is missing from it as that module is pure-python. \$\endgroup\$
    – Eric
    Commented Dec 25, 2020 at 0:17
  • \$\begingroup\$ Thanks for the tip. I already migrated to a static set of standard library modules, as you can see in the latest gist. \$\endgroup\$ Commented Dec 25, 2020 at 1:02
3
\$\begingroup\$

Bugs

FROM_IMPORT = 'from ([\\w\\.]+) import (.*)'

If code is written as from x import y with more than one space after from or before import, the regex won't match. You should use \s+ in the regex.

You'll also have problems with accidental matches inside strings:

"""
Example usage:
   import your_module
   ...
"""

Use a real Python parser (ast) or actually import the files and look at the modules that actually got imported, which may find imports that were loaded through importlib

\$\endgroup\$
0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.