blob: 1a8a0f0094e7c694f3006abd83012411311c5e9e [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2023 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import argparse
import ast
import dataclasses
import json
import os
@dataclasses.dataclass
class Finding:
message: str
line: int
col: int = 0
end_line: int = 0
end_col: int = 0
replacements: list = ()
level: str = "error"
def main():
parser = argparse.ArgumentParser(description="Enforce the recipe style guide")
parser.add_argument("filename", help="Name of the file to check.")
args = parser.parse_args()
with open(args.filename) as f:
tree = ast.parse(f.read(), filename=args.filename)
findings = []
# PROPERTIES declarations may appear in recipe files or in recipe module
# __init__.py files.
findings.extend(check_properties_type(args.filename, tree))
if is_recipe_file(args.filename):
findings.extend(check_recipe_function_naming(tree))
findings.extend(check_recipe_function_ordering(tree))
print(
json.dumps(
[{"filepath": args.filename, **dataclasses.asdict(f)} for f in findings],
indent=2,
)
)
def is_recipe_file(path):
if not path.endswith(".py"):
return False
path_parts = path.split(os.sep)
if any(p.endswith(".resources") for p in path_parts):
return False
if path_parts[0] == "recipes":
return True
if path_parts[0] == "recipe_modules" and path_parts[-2] in ("tests", "examples"):
return True
return False
def check_recipe_function_naming(tree: ast.AST):
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name.startswith("_"):
# node.col_offset is the column of the "def" keyword, but it's nicer
# if we only highlight the function name instead of the whole "def"
# line. Note that this is fragile, as it assumes only one space
# between the "def" keyword and the function name, which may not be
# the case if the code has not been auto-formatted.
col = node.col_offset + len("def ") + 1
yield Finding(
line=node.lineno,
end_line=node.lineno,
col=col,
end_col=col + len(node.name),
message=f"{node.name} should not start with an underscore",
replacements=[node.name.lstrip("_")],
)
def check_recipe_function_ordering(tree: ast.AST):
function_defs = [node for node in tree.body if isinstance(node, ast.FunctionDef)]
for index, node in enumerate(function_defs):
if node.name == "RunSteps" and index != 0:
yield Finding(
line=node.lineno,
end_line=node.lineno,
message=(
f"{node.name} should be the first function in a recipe. "
"Helper functions should be between RunSteps and GenTests."
),
)
if node.name == "GenTests" and index != len(function_defs) - 1:
yield Finding(
line=node.lineno,
end_line=node.lineno,
message=(
f"{node.name} should be the last function in a recipe. "
"Helper functions should be between RunSteps and GenTests."
),
)
# TODO(fxbug.dev/88250): Trim down this allowlist. *Do not* add new files to
# this allowlist.
DICT_PROPERTIES_ALLOWLIST = [
"recipes/contrib/dart_toolchain.py",
"recipes/contrib/goma_client.py",
"recipes/contrib/goma_gcp_configurator.py",
"recipes/contrib/goma_gcp_deployer.py",
"recipes/contrib/goma_toolchain.py",
"recipes/contrib/goma_windows_images.py",
"recipes/contrib/gomatools_builder.py",
"recipes/contrib/gomatools_images_roller.py",
]
def check_properties_type(filename: str, tree: ast.AST):
path_parts = filename.split(os.sep)
# Recipe module test recipes are allowed to use dict-style properties for
# simplicity.
if path_parts[0] == "recipe_modules" and path_parts[-1] != "__init__.py":
return
for node in tree.body:
if not isinstance(node, ast.Assign) or node.targets[0].id != "PROPERTIES":
continue
if isinstance(node.value, ast.Dict):
if filename not in DICT_PROPERTIES_ALLOWLIST:
yield Finding(
message="use proto properties instead of dict properties",
line=node.lineno,
end_line=node.end_lineno,
col=node.col_offset + 1,
end_col=node.end_col_offset + 1,
)
elif filename in DICT_PROPERTIES_ALLOWLIST:
yield Finding(
message=f"remove {filename} from DICT_PROPERTIES_ALLOWLIST",
line=node.lineno,
end_line=node.end_lineno,
col=node.col_offset + 1,
end_col=node.end_col_offset + 1,
)
if __name__ == "__main__":
main()