summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/spack/spack/cmd/style.py162
-rw-r--r--lib/spack/spack/test/cmd/style.py99
2 files changed, 241 insertions, 20 deletions
diff --git a/lib/spack/spack/cmd/style.py b/lib/spack/spack/cmd/style.py
index d16ff34277..5925d1120e 100644
--- a/lib/spack/spack/cmd/style.py
+++ b/lib/spack/spack/cmd/style.py
@@ -3,10 +3,12 @@
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import argparse
+import ast
import os
import re
import sys
from itertools import zip_longest
+from typing import Dict, List, Optional
import llnl.util.tty as tty
import llnl.util.tty.color as color
@@ -14,7 +16,7 @@ from llnl.util.filesystem import working_dir
import spack.paths
import spack.util.git
-from spack.util.executable import which
+from spack.util.executable import Executable, which
description = "runs source code style checks on spack"
section = "developer"
@@ -36,10 +38,7 @@ exclude_directories = [os.path.relpath(spack.paths.external_path, spack.paths.pr
#: double-check the results of other tools (if, e.g., --fix was provided)
#: The list maps an executable name to a method to ensure the tool is
#: bootstrapped or present in the environment.
-tool_names = ["isort", "black", "flake8", "mypy"]
-
-#: tools we run in spack style
-tools = {}
+tool_names = ["import-check", "isort", "black", "flake8", "mypy"]
#: warnings to ignore in mypy
mypy_ignores = [
@@ -61,14 +60,28 @@ def is_package(f):
#: decorator for adding tools to the list
class tool:
- def __init__(self, name, required=False):
+ def __init__(self, name: str, required: bool = False, external: bool = True) -> None:
self.name = name
+ self.external = external
self.required = required
def __call__(self, fun):
- tools[self.name] = (fun, self.required)
+ self.fun = fun
+ tools[self.name] = self
return fun
+ @property
+ def installed(self) -> bool:
+ return bool(which(self.name)) if self.external else True
+
+ @property
+ def executable(self) -> Optional[Executable]:
+ return which(self.name) if self.external else None
+
+
+#: tools we run in spack style
+tools: Dict[str, tool] = {}
+
def changed_files(base="develop", untracked=True, all_files=False, root=None):
"""Get list of changed files in the Spack repository.
@@ -176,22 +189,22 @@ def setup_parser(subparser):
"-t",
"--tool",
action="append",
- help="specify which tools to run (default: %s)" % ",".join(tool_names),
+ help="specify which tools to run (default: %s)" % ", ".join(tool_names),
)
tool_group.add_argument(
"-s",
"--skip",
metavar="TOOL",
action="append",
- help="specify tools to skip (choose from %s)" % ",".join(tool_names),
+ help="specify tools to skip (choose from %s)" % ", ".join(tool_names),
)
subparser.add_argument("files", nargs=argparse.REMAINDER, help="specific files to check")
-def cwd_relative(path, args):
+def cwd_relative(path, root, initial_working_dir):
"""Translate prefix-relative path to current working directory-relative."""
- return os.path.relpath(os.path.join(args.root, path), args.initial_working_dir)
+ return os.path.relpath(os.path.join(root, path), initial_working_dir)
def rewrite_and_print_output(
@@ -201,7 +214,10 @@ def rewrite_and_print_output(
# print results relative to current working directory
def translate(match):
- return replacement.format(cwd_relative(match.group(1), args), *list(match.groups()[1:]))
+ return replacement.format(
+ cwd_relative(match.group(1), args.root, args.initial_working_dir),
+ *list(match.groups()[1:]),
+ )
for line in output.split("\n"):
if not line:
@@ -220,7 +236,7 @@ def print_style_header(file_list, args, tools_to_run):
# translate modified paths to cwd_relative if needed
paths = [filename.strip() for filename in file_list]
if not args.root_relative:
- paths = [cwd_relative(filename, args) for filename in paths]
+ paths = [cwd_relative(filename, args.root, args.initial_working_dir) for filename in paths]
tty.msg("Modified files", *paths)
sys.stdout.flush()
@@ -352,17 +368,127 @@ def run_black(black_cmd, file_list, args):
return returncode
+def _module_part(root: str, expr: str):
+ parts = expr.split(".")
+ while parts:
+ f1 = os.path.join(root, "lib", "spack", *parts) + ".py"
+ f2 = os.path.join(root, "lib", "spack", *parts, "__init__.py")
+ if os.path.exists(f1) or os.path.exists(f2):
+ return ".".join(parts)
+ parts.pop()
+ return None
+
+
+def _run_import_check(
+ file_list: List[str],
+ *,
+ fix: bool,
+ root_relative: bool,
+ root=spack.paths.prefix,
+ working_dir=spack.paths.prefix,
+ out=sys.stdout,
+):
+ if sys.version_info < (3, 9):
+ print("import-check requires Python 3.9 or later")
+ return 0
+
+ is_use = re.compile(r"(?<!from )(?<!import )(?:llnl|spack)\.[a-zA-Z0-9_\.]+")
+
+ # redundant imports followed by a `# comment` are ignored, cause there can be legimitate reason
+ # to import a module: execute module scope init code, or to deal with circular imports.
+ is_abs_import = re.compile(r"^import ((?:llnl|spack)\.[a-zA-Z0-9_\.]+)$", re.MULTILINE)
+
+ exit_code = 0
+
+ for file in file_list:
+ to_add = set()
+ to_remove = []
+
+ pretty_path = file if root_relative else cwd_relative(file, root, working_dir)
+
+ try:
+ with open(file, "r") as f:
+ contents = open(file, "r").read()
+ parsed = ast.parse(contents)
+ except Exception:
+ exit_code = 1
+ print(f"{pretty_path}: could not parse", file=out)
+ continue
+
+ for m in is_abs_import.finditer(contents):
+ if contents.count(m.group(1)) == 1:
+ to_remove.append(m.group(0))
+ exit_code = 1
+ print(f"{pretty_path}: redundant import: {m.group(1)}", file=out)
+
+ # Clear all strings to avoid matching comments/strings etc.
+ for node in ast.walk(parsed):
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ node.value = ""
+
+ filtered_contents = ast.unparse(parsed) # novermin
+ for m in is_use.finditer(filtered_contents):
+ module = _module_part(root, m.group(0))
+ if not module or module in to_add:
+ continue
+ if f"import {module}" not in filtered_contents:
+ to_add.add(module)
+ exit_code = 1
+ print(f"{pretty_path}: missing import: {module}", file=out)
+
+ if not fix or not to_add and not to_remove:
+ continue
+
+ with open(file, "r") as f:
+ lines = f.readlines()
+
+ if to_add:
+ # insert missing imports before the first import, delegate ordering to isort
+ for node in parsed.body:
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
+ first_line = node.lineno
+ break
+ else:
+ print(f"{pretty_path}: could not fix", file=out)
+ continue
+ lines.insert(first_line, "\n".join(f"import {x}" for x in to_add) + "\n")
+
+ new_contents = "".join(lines)
+
+ # remove redundant imports
+ for statement in to_remove:
+ new_contents = new_contents.replace(f"{statement}\n", "")
+
+ with open(file, "w") as f:
+ f.write(new_contents)
+
+ return exit_code
+
+
+@tool("import-check", external=False)
+def run_import_check(import_check_cmd, file_list, args):
+ exit_code = _run_import_check(
+ file_list,
+ fix=args.fix,
+ root_relative=args.root_relative,
+ root=args.root,
+ working_dir=args.initial_working_dir,
+ )
+ print_tool_result("import-check", exit_code)
+ return exit_code
+
+
def validate_toolset(arg_value):
"""Validate --tool and --skip arguments (sets of optionally comma-separated tools)."""
tools = set(",".join(arg_value).split(",")) # allow args like 'isort,flake8'
for tool in tools:
if tool not in tool_names:
- tty.die("Invaild tool: '%s'" % tool, "Choose from: %s" % ", ".join(tool_names))
+ tty.die("Invalid tool: '%s'" % tool, "Choose from: %s" % ", ".join(tool_names))
return tools
-def missing_tools(tools_to_run):
- return [t for t in tools_to_run if which(t) is None]
+def missing_tools(tools_to_run: List[str]) -> List[str]:
+ return [t for t in tools_to_run if not tools[t].installed]
def _bootstrap_dev_dependencies():
@@ -417,9 +543,9 @@ def style(parser, args):
print_style_header(file_list, args, tools_to_run)
for tool_name in tools_to_run:
- run_function, required = tools[tool_name]
+ tool = tools[tool_name]
print_tool_header(tool_name)
- return_code |= run_function(which(tool_name), file_list, args)
+ return_code |= tool.fun(tool.executable, file_list, args)
if return_code == 0:
tty.msg(color.colorize("@*{spack style checks were clean}"))
diff --git a/lib/spack/spack/test/cmd/style.py b/lib/spack/spack/test/cmd/style.py
index 208e31f8a2..eab4de18dd 100644
--- a/lib/spack/spack/test/cmd/style.py
+++ b/lib/spack/spack/test/cmd/style.py
@@ -4,8 +4,11 @@
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import filecmp
+import io
import os
+import pathlib
import shutil
+import sys
import pytest
@@ -15,7 +18,7 @@ import spack.cmd.style
import spack.main
import spack.paths
import spack.repo
-from spack.cmd.style import changed_files
+from spack.cmd.style import _run_import_check, changed_files
from spack.util.executable import which
#: directory with sample style files
@@ -292,5 +295,97 @@ def test_style_with_black(flake8_package_with_errors):
def test_skip_tools():
- output = style("--skip", "isort,mypy,black,flake8")
+ output = style("--skip", "import-check,isort,mypy,black,flake8")
assert "Nothing to run" in output
+
+
+@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires Python 3.9+")
+def test_run_import_check(tmp_path: pathlib.Path):
+ file = tmp_path / "issues.py"
+ contents = '''
+import spack.cmd
+import spack.config # do not drop this import because of this comment
+
+# this comment about spack.error should not be removed
+class Example(spack.build_systems.autotools.AutotoolsPackage):
+ """this is a docstring referencing unused spack.error.SpackError, which is fine"""
+ pass
+
+def foo(config: "spack.error.SpackError"):
+ # the type hint is quoted, so it should not be removed
+ spack.util.executable.Executable("example")
+'''
+ file.write_text(contents)
+ root = str(tmp_path)
+ output_buf = io.StringIO()
+ exit_code = _run_import_check(
+ [str(file)],
+ fix=False,
+ out=output_buf,
+ root_relative=False,
+ root=spack.paths.prefix,
+ working_dir=root,
+ )
+ output = output_buf.getvalue()
+
+ assert "issues.py: redundant import: spack.cmd" in output
+ assert "issues.py: redundant import: spack.config" not in output # comment prevents removal
+ assert "issues.py: missing import: spack.build_systems.autotools" in output
+ assert "issues.py: missing import: spack.util.executable" in output
+ assert "issues.py: missing import: spack.error" not in output # not directly used
+ assert exit_code == 1
+ assert file.read_text() == contents # fix=False should not change the file
+
+ # run it with --fix, should have the same output.
+ output_buf = io.StringIO()
+ exit_code = _run_import_check(
+ [str(file)],
+ fix=True,
+ out=output_buf,
+ root_relative=False,
+ root=spack.paths.prefix,
+ working_dir=root,
+ )
+ output = output_buf.getvalue()
+ assert exit_code == 1
+ assert "issues.py: redundant import: spack.cmd" in output
+ assert "issues.py: missing import: spack.build_systems.autotools" in output
+ assert "issues.py: missing import: spack.util.executable" in output
+
+ # after fix a second fix is idempotent
+ output_buf = io.StringIO()
+ exit_code = _run_import_check(
+ [str(file)],
+ fix=True,
+ out=output_buf,
+ root_relative=False,
+ root=spack.paths.prefix,
+ working_dir=root,
+ )
+ output = output_buf.getvalue()
+ assert exit_code == 0
+ assert not output
+
+ # check that the file was fixed
+ new_contents = file.read_text()
+ assert "import spack.cmd" not in new_contents
+ assert "import spack.build_systems.autotools" in new_contents
+ assert "import spack.util.executable" in new_contents
+
+
+@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires Python 3.9+")
+def test_run_import_check_syntax_error_and_missing(tmp_path: pathlib.Path):
+ (tmp_path / "syntax-error.py").write_text("""this 'is n(ot python code""")
+ output_buf = io.StringIO()
+ exit_code = _run_import_check(
+ [str(tmp_path / "syntax-error.py"), str(tmp_path / "missing.py")],
+ fix=False,
+ out=output_buf,
+ root_relative=True,
+ root=str(tmp_path),
+ working_dir=str(tmp_path / "does-not-matter"),
+ )
+ output = output_buf.getvalue()
+ assert "syntax-error.py: could not parse" in output
+ assert "missing.py: could not parse" in output
+ assert exit_code == 1