#!/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)