Files
2026-07-13 13:17:40 +08:00

441 lines
15 KiB
Python

#!/usr/bin/env python3
import argparse
import glob
import json
import logging
import os
import re
import shutil
import subprocess
from typing import Dict, List, Optional, Set, Tuple
import yaml
logger = logging.getLogger("ray_oss_analysis")
def _setup_logger(log_file: Optional[str] = None, enable_debug: bool = False) -> None:
"""
Setup logger for the script.
You can either use default console logger or enable additional file logger if needed by passing the log_file.
Setting the log level to debug if enable_debug is true.
"""
logger.setLevel(logging.DEBUG if enable_debug else logging.INFO)
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
if log_file:
file_handler = logging.FileHandler(log_file, mode="w")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
logger.info(f"Logging to file: {log_file}")
logger.info(f"Log Level Set to {logging.getLevelName(logger.level)}")
# not being used since we moved to filtering only for source files to get c, cpp libraries
def _is_excluded_kind(kind_str: str) -> bool:
"""
Check if the kind is excluded.
"""
# split the kind_str by whitespace and get the first element as the kind
kind = kind_str.split(" ")[0]
# list of non-target rule kinds
non_target_rule_kinds = [
"config_setting",
"pkg_files",
"pkg_zip_impl",
"int_flag",
"string_flag",
"bool_flag",
"bind",
"constraint_value",
"constraint_setting",
"GENERATED_FILE",
]
python_rule_kinds = ["py_library", "py_binary", "py_test"]
target_rule_kinds = non_target_rule_kinds + python_rule_kinds
return kind in target_rule_kinds
def _is_build_tool(label: str) -> bool:
"""
Check if the label is a build tool.
"""
# list of build package labels that are present in dependencies but not part of the target code
build_package_labels = [
"bazel_tools",
"local_config_python",
"cython",
"local_config_cc",
]
return any(
build_package_label in label for build_package_label in build_package_labels
)
def _is_own_code(location: str) -> bool:
"""
Check if it is own code or not.
"""
if location is None:
return False
else:
return location.startswith(os.getcwd())
def _is_cpp_code(location: str) -> bool:
"""
Check if the label is C/C++ code.
"""
# list of C/C++ file extensions
cExtensions = [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hpp", ".hxx"]
return any(location.endswith(ext) for ext in cExtensions)
def _get_dependency_info(line_json: Dict) -> Tuple[str, str, str, str]:
"""
Get dependency info from the json line.
"""
# earlier when we were getting all types of packages there was a need to get the type of the package,
# but now we are only getting source files and we are not interested in the type of the package
# Yet we are keeping the code here, for future needs
type = line_json["type"]
match type:
case "SOURCE_FILE":
return (
type,
type,
line_json["sourceFile"]["name"],
_clean_path(line_json["sourceFile"]["location"]),
)
case "GENERATED_FILE":
return (
type,
type,
line_json["generatedFile"]["name"],
_clean_path(line_json["generatedFile"]["location"]),
)
case "PACKAGE_GROUP":
return type, type, line_json["packageGroup"]["name"], None
case "RULE":
return (
type,
line_json["rule"]["ruleClass"],
line_json["rule"]["name"],
line_json["rule"]["location"],
)
case _:
return type, type, "unknown", "unknown"
def _clean_path(path: str) -> str:
"""
Clean the path by removing location info.
"""
# Remove location information (e.g., :line:column) from the path
# Format is typically: /path/to/file.ext:line:column
return path.split(":")[0]
def _get_package_name(label: str) -> Optional[str]:
"""
Extract package name from bazel label.
matches @repo//pkg:target to repo. regex breaks the string into groups and we return the first group.
separated out so that this can be tested.
Returns None if the label doesn't have an external package name (e.g., local targets like //pkg:target).
"""
match = re.search(r"(?:@([^/]+))?//", label)
if match:
return match.group(1)
return None
def _get_bazel_dependencies(
package_name: str, bazel_command: str
) -> Tuple[Set[str], Set[str]]:
"""
Returns the package names and file paths of the dependencies minus build tools and own code.
Currently works only for c, cpp.
"""
# package names of dependencies
# file paths for actual files used
package_names = set()
file_paths = set()
# works for c, cpp, not sure if the kind based filter works for other languages
command = [
bazel_command,
"query",
"--output=streamed_jsonproto",
f"kind('source file', deps({package_name}))",
]
logger.debug(f"Running command: {command}")
lines = subprocess.check_output(command, text=True).splitlines()
logger.debug(f"Found {len(lines)} dependencies")
for line in lines:
line_json = json.loads(line)
type, kind, label, location = _get_dependency_info(line_json)
logger.debug(f"Dependency type: {type}, Label: {label}, Location: {location}")
if _is_build_tool(label) or _is_own_code(location):
logger.debug(f"Skipping dependency: {line} because it is a bad kind")
continue
elif _is_cpp_code(location):
file_paths.add(location)
package_name = _get_package_name(label)
if package_name is not None:
package_names.add(package_name)
return package_names, file_paths
def _copy_single_file(source: str, destination: str) -> None:
"""
Copy a single file from source to destination.
"""
# Create parent directories if they don't exist
os.makedirs(os.path.dirname(destination), exist_ok=True)
# Copy the file
try:
shutil.copy(source, destination)
except FileNotFoundError:
logger.warning(f"File not found, skipping: {source}")
def _copy_files(file_paths: Set[str], output_folder: str) -> None:
"""
Copy files to output folder.
"""
for file_path in file_paths:
logger.debug(f"Copying file: {file_path}")
destination = os.path.join(output_folder, file_path.split("external/")[-1])
_copy_single_file(file_path, destination)
def _copy_licenses(
package_names: Set[str], bazel_output_base: str, output_folder: str
) -> None:
"""
Copy licenses to output folder.
"""
for package_name in package_names:
license_paths = _expand_license_files(
os.path.join(bazel_output_base, "external", package_name)
)
for license_path in license_paths:
_copy_single_file(
license_path,
os.path.join(output_folder, license_path.split("external/")[-1]),
)
def _askalono_crawl(path: str) -> List[Dict]:
"""
Crawl licenses using askalono.
"""
license_text = subprocess.check_output(
["askalono", "--format=json", "crawl", path],
text=True,
).strip()
licenses = [json.loads(license_text) for license_text in license_text.splitlines()]
cleaned_licenses = [license for license in licenses if "error" not in license]
error_licenses = [license for license in licenses if "error" in license]
for error_license in error_licenses:
logger.debug(
f"License Crawl failed for {error_license['path']}: {error_license['error']}"
)
return cleaned_licenses
def _expand_license_files(path: str) -> List[str]:
"""
Expand license files using glob patterns.
"""
patterns = [
"**/[Ll][Ii][Cc][Ee][Nn][Ss][Ee]*", # LICENSE
"**/[Cc][Oo][Pp][Yy][Ii][Nn][Gg]*", # COPYING
"**/[Nn][Oo][Tt][Ii][Cc][Ee]*", # NOTICE
"**/[Cc][Oo][Pp][Yy][Rr][Ii][Gg][Hh][Tt]*", # COPYRIGHT
"**/[Rr][Ee][Aa][Dd][Mm][Ee]*", # README
]
all_paths = set()
for pattern in patterns:
full_pattern = os.path.join(path, pattern)
matching_paths = glob.glob(full_pattern, recursive=True)
logger.debug(f"Pattern {full_pattern} matched {len(matching_paths)} files")
# Only include files, not directories
all_paths.update(p for p in matching_paths if os.path.isfile(p))
return list(all_paths)
def _expand_and_crawl(path: str) -> List[Dict]:
"""
Given a path, crawl licenses using askalono.
Works recursively for all licenses in the path.
"""
license_paths = _expand_license_files(path)
all_licenses = []
for license_path in license_paths:
licenses = _askalono_crawl(license_path)
all_licenses.extend(licenses)
return all_licenses
def _get_askalono_results(dependencies: Set[str], bazel_output_base: str) -> List[Dict]:
"""
Get askalono results for all dependencies.
"""
license_info = []
for dependency in dependencies:
dependency_path = os.path.join(bazel_output_base, "external", dependency)
license_json = _askalono_crawl(dependency_path)
if not license_json:
logger.warning(
f"No license text found for {dependency}, trying to crawl licenses and copying files manually"
)
license_json = _expand_and_crawl(dependency_path)
if not license_json:
logger.warning(f"No license text found for {dependency}")
license_info.append(
{
"dependency": dependency,
"path": "unknown",
"license": "unknown",
}
)
continue
for license in license_json:
license_info.append(
{
"dependency": dependency,
"path": license["path"].split("external/")[-1],
"license": license["result"]["license"]["name"],
}
)
return license_info
def _generate_fossa_deps_file(askalono_results: List[Dict], output_folder: str) -> None:
"""
Generate fossa dependencies file from askalono results.
"""
# Group licenses and file paths by dependency
dependency_data = {}
for result in askalono_results:
logger.debug("generating fossa deps file: result: %s", result)
dep = result["dependency"]
license_name = result["license"]
license_file_path = result.get("path", "N/A")
if dep not in dependency_data:
dependency_data[dep] = {"licenses": set(), "file_licenses": []}
dependency_data[dep]["licenses"].add(license_name)
dependency_data[dep]["file_licenses"].append(
f"{license_file_path.split('external/')[-1]}: {license_name}"
)
# Create custom dependencies with aggregated licenses
custom_dependencies = []
for dependency, data in sorted(dependency_data.items()):
licenses = data["licenses"]
file_licenses = data["file_licenses"]
logger.debug(
f"generating fossa deps file: Dependency: {dependency}, Licenses: {licenses}"
)
# Build description with file paths and licenses
description_parts = ["generated by ray_oss_analysis.py, askalono scan results."]
if file_licenses:
description_parts.append("License files: " + "; ".join(file_licenses))
custom_dependencies.append(
{
"name": dependency,
"license": " or ".join(sorted(licenses)),
"version": "Non-versioned",
"metadata": {
"description": " ".join(description_parts),
},
}
)
fossa_deps_file = {"custom-dependencies": custom_dependencies}
# Write to YAML file
with open(os.path.join(output_folder, "fossa_deps.yaml"), "w") as file:
yaml.dump(fossa_deps_file, file, indent=4, sort_keys=False)
def _change_working_directory() -> None:
"""
Change working directory to the workspace in case being executed as bazel py_binary.
"""
workspace = os.environ.get("BUILD_WORKING_DIRECTORY")
if workspace:
os.chdir(workspace)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="OSS Analysis Combo Tool")
parser.add_argument("-o", "--output", help="Output folder path", required=True)
parser.add_argument(
"-v", "--verbose", action="store_true", help="Enable verbose output"
)
parser.add_argument(
"-cmd", "--bazel-cmd", help="Bazel command bazel/bazelisk", default="bazel"
)
parser.add_argument(
"-p", "--package", help="Bazel package", default="//:gen_ray_pkg"
)
parser.add_argument("--log-file", help="Log file path")
parser.add_argument(
"--copy-files-for-fossa",
action="store_true",
help="Copy files for fossa analysis on top of askalono",
)
parser.formatter_class = argparse.RawTextHelpFormatter
parser.description = """
Ray OSS Analysis Tool - Analyze Ray's open source components
current status: scans only c, cpp libraries are scanned and scanned via askalono
"""
parser.epilog = """
Examples:
%(prog)s --output oss_analysis -cmd bazelisk # if bazel is not present or you would prefer bazelisk
%(prog)s --output oss_analysis # if bazel is present
%(prog)s --verbose
"""
args = parser.parse_args()
_change_working_directory()
_setup_logger(args.log_file, args.verbose)
bazel_output_base = subprocess.check_output(
[args.bazel_cmd, "info", "output_base"], text=True
).strip()
package_names, file_paths = _get_bazel_dependencies(args.package, args.bazel_cmd)
logger.info(f"Found {len(file_paths)} file paths")
logger.info(f"Found {len(package_names)} package names")
if args.copy_files_for_fossa:
_copy_files(file_paths, args.output)
_copy_licenses(package_names, bazel_output_base, args.output)
askalono_results = _get_askalono_results(package_names, bazel_output_base)
with open(os.path.join(args.output, "askalono_results.json"), "w") as file:
json.dump(askalono_results, file, indent=4)
_generate_fossa_deps_file(askalono_results, args.output)