mirror of
https://github.com/hanzo-ml/kserve.git
synced 2026-10-30 20:53:49 +00:00
369 lines
13 KiB
Python
369 lines
13 KiB
Python
# Copyright 2025 The KServe Authors.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# ---
|
|
# Portions of this software are derived from:
|
|
|
|
# - pip-licenses (https://github.com/raimon49/pip-licenses)
|
|
# Licensed under the MIT License:
|
|
|
|
# Copyright (c) 2018 raimon
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
|
|
# The above copyright notice and this permission notice shall be included in all
|
|
# copies or substantial portions of the Software.
|
|
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
import argparse
|
|
import importlib.metadata
|
|
import os
|
|
import requests
|
|
import logging
|
|
from typing import Dict, List, Optional
|
|
|
|
try:
|
|
import tomllib # Python 3.11+
|
|
except ImportError:
|
|
import tomli as tomllib
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
)
|
|
|
|
logger = logging.getLogger("piplicenses")
|
|
|
|
|
|
def read_pyproject_config(pyproject_path: str) -> Dict[str, str]:
|
|
path = pyproject_path + "/pyproject.toml"
|
|
if not os.path.exists(path):
|
|
logger.info(f"No pyproject.toml found at: {pyproject_path}")
|
|
return {}
|
|
try:
|
|
with open(path, "rb") as f:
|
|
toml_data = tomllib.load(f)
|
|
return {
|
|
k.replace("-", "_"): v
|
|
for k, v in toml_data.get("tool", {}).get("pip-licenses", {}).items()
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error reading pyproject.toml at {pyproject_path}: {e}")
|
|
return {}
|
|
|
|
|
|
def parse_arguments(toml_config: Dict[str, str]) -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate a list of installed Python packages and their license information."
|
|
)
|
|
parser.add_argument(
|
|
"--from",
|
|
dest="license_source",
|
|
choices=["meta", "classifier", "mixed"],
|
|
default=toml_config.get("from", "mixed"),
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
dest="output_format",
|
|
choices=["plain-vertical"],
|
|
default=toml_config.get("format", "plain-vertical"),
|
|
)
|
|
parser.add_argument(
|
|
"--with-license-file",
|
|
action="store_true",
|
|
default=toml_config.get("with_license_file", False),
|
|
)
|
|
parser.add_argument(
|
|
"--with-notice-file",
|
|
action="store_true",
|
|
default=toml_config.get("with_notice_file", False),
|
|
)
|
|
parser.add_argument(
|
|
"--with-urls", action="store_true", default=toml_config.get("with_urls", False)
|
|
)
|
|
parser.add_argument(
|
|
"--output-path", type=str, default=toml_config.get("output_path", ".")
|
|
)
|
|
parser.add_argument(
|
|
"--ignore-packages",
|
|
type=str,
|
|
nargs="+",
|
|
action="store",
|
|
default=toml_config.get("ignore_packages", []),
|
|
help="Space-separated list of packages to ignore for license check, but include license text.",
|
|
)
|
|
parser.add_argument(
|
|
"--allow-only", type=str, default=toml_config.get("allow_only", None)
|
|
)
|
|
parser.add_argument(
|
|
"--package-url",
|
|
type=str,
|
|
default=None,
|
|
help="Specify packages and URLs to retrieve licenses from. Format: 'pkg_name:url [pkg_name2:url2 ...]'",
|
|
)
|
|
|
|
return parser.parse_known_args()[0]
|
|
|
|
|
|
def get_license(pkg: importlib.metadata.Distribution, source: str) -> str:
|
|
if source == "classifier":
|
|
for classifier in pkg.metadata.get_all("Classifier", []):
|
|
if classifier.startswith("License ::"):
|
|
license_name = classifier.split("::")[-1].strip()
|
|
if license_name != "OSI Approved":
|
|
return license_name
|
|
elif source == "meta":
|
|
if "License-Expression" in pkg.metadata:
|
|
return pkg.metadata["License-Expression"]
|
|
else:
|
|
return pkg.metadata.get("License", "UNKNOWN")
|
|
elif source == "mixed":
|
|
return get_license(pkg, "classifier") or get_license(pkg, "meta")
|
|
return "UNKNOWN"
|
|
|
|
|
|
def get_installed_packages() -> List[importlib.metadata.Distribution]:
|
|
return [pkg for pkg in importlib.metadata.distributions()]
|
|
|
|
|
|
def find_file_content(
|
|
pkg: importlib.metadata.Distribution, filenames: List[str]
|
|
) -> Optional[str]:
|
|
try:
|
|
package_path = pkg.locate_file("")
|
|
for root, _, files in os.walk(package_path):
|
|
for name in files:
|
|
if name.upper() in filenames:
|
|
with open(
|
|
os.path.join(root, name), encoding="utf-8", errors="ignore"
|
|
) as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Error reading local license/notice for {pkg.metadata.get('Name')}: {e}"
|
|
)
|
|
return None
|
|
|
|
|
|
def fetch_remote_file_from_repo(url: str, filenames: List[str]) -> Optional[str]:
|
|
if "github.com" not in url:
|
|
return None
|
|
|
|
url = url.rstrip("/")
|
|
if "tree" in url:
|
|
url = url.replace("/tree", "")
|
|
|
|
repo_base = url.replace("github.com", "raw.githubusercontent.com")
|
|
|
|
for branch in ["main", "master"]:
|
|
base = f"{repo_base}/{branch}"
|
|
for name in filenames:
|
|
try:
|
|
raw_url = f"{base}/{name}"
|
|
response = requests.get(raw_url, timeout=3)
|
|
if response.ok and response.text.strip():
|
|
return response.text.strip()
|
|
except Exception as e:
|
|
logger.error(f"Failed to fetch {name} from {raw_url}: {e}")
|
|
|
|
return None
|
|
|
|
|
|
def fetch_license_name_from_text(content: str) -> Optional[str]:
|
|
license_name = "UNKNOWN"
|
|
content_lines: list[str] = content.splitlines()
|
|
# Get the license name from the first line of license text or the second line
|
|
# if the first line is empty or whitespace
|
|
if len(content_lines) > 0:
|
|
license_name = content_lines[0].strip()
|
|
if license_name.isspace() or not license_name:
|
|
license_name = (
|
|
content_lines[1].strip() if len(content_lines) > 1 else "UNKNOWN"
|
|
)
|
|
return license_name
|
|
|
|
|
|
def check_license_allowlist(
|
|
license_info: str,
|
|
allow_only: Optional[List[str]],
|
|
pkg_name: str,
|
|
ignore_list: List[str],
|
|
) -> None:
|
|
if pkg_name not in ignore_list and (
|
|
allow_only is None or license_info not in allow_only
|
|
):
|
|
raise ValueError(
|
|
f"License '{license_info}' is not in the allowed list found for package {pkg_name}"
|
|
)
|
|
|
|
|
|
def format_plain_vertical(
|
|
packages: List[importlib.metadata.Distribution],
|
|
package_urls: Dict[str, str],
|
|
license_source: str,
|
|
with_license_file: bool,
|
|
with_notice_file: bool,
|
|
with_urls: bool,
|
|
allow_only: Optional[List[str]],
|
|
ignore_list: Optional[List[str]] = None,
|
|
) -> tuple[str, str]:
|
|
license_output = []
|
|
notice_output = []
|
|
for pkg in packages:
|
|
name = pkg.metadata.get("Name", "")
|
|
version = pkg.version
|
|
license_info = get_license(pkg, license_source)
|
|
# If license_info is a long string it is probably a full license text, fetch the license name from the text
|
|
if len(license_info.splitlines()) > 2:
|
|
license_info = fetch_license_name_from_text(license_info)
|
|
url = pkg.metadata.get("Home-page") or pkg.metadata.get("Project-URL") or ""
|
|
|
|
lines = [name, version, license_info]
|
|
if with_urls:
|
|
lines.append(url)
|
|
|
|
# Header for license and notice sections
|
|
header = f"{'=' * 120}\n{name.center(0)} {version.center(35)} {license_info.center(35)}"
|
|
if not url:
|
|
url = package_urls.get(name, "")
|
|
if with_urls:
|
|
header += f" {url.center(35)}"
|
|
|
|
header += f"\n{'=' * 120}"
|
|
|
|
# Check if license is in allowlist
|
|
if license_info != "UNKNOWN" and pkg.metadata.get("Name", "").lower() not in (
|
|
name.lower() for name in ignore_list
|
|
):
|
|
check_license_allowlist(
|
|
license_info, allow_only, pkg.metadata.get("Name"), ignore_list
|
|
)
|
|
else:
|
|
content = find_file_content(pkg, ["LICENSE", "LICENCE", "COPYING"])
|
|
if not content and license_info == "UNKNOWN" and url:
|
|
logger.info(
|
|
f"Fetching license for package {pkg.metadata.get('Name')} from {url}"
|
|
)
|
|
content = fetch_remote_file_from_repo(
|
|
url, ["LICENSE", "LICENCE", "COPYING"]
|
|
)
|
|
license_name = fetch_license_name_from_text(content)
|
|
check_license_allowlist(
|
|
license_name, allow_only, pkg.metadata.get("Name"), ignore_list
|
|
)
|
|
|
|
# License file
|
|
if with_license_file:
|
|
content = find_file_content(pkg, ["LICENSE", "LICENCE", "COPYING"])
|
|
if not content and license_info == "UNKNOWN" and url:
|
|
content = fetch_remote_file_from_repo(
|
|
url, ["LICENSE", "LICENCE", "COPYING"]
|
|
)
|
|
if content:
|
|
license_output.append(f"{header}\n{content}")
|
|
|
|
# Notice file
|
|
if with_notice_file:
|
|
content = find_file_content(pkg, ["NOTICE"])
|
|
if not content and license_info == "UNKNOWN" and url:
|
|
content = fetch_remote_file_from_repo(url, ["NOTICE"])
|
|
if content:
|
|
notice_output.append(f"{header}\n{content}")
|
|
|
|
return (
|
|
"\n\n".join(license_output),
|
|
"\n\n".join(notice_output),
|
|
)
|
|
|
|
|
|
def handle_package_urls(args):
|
|
parsed_package_urls = {}
|
|
if not args.package_url:
|
|
return parsed_package_urls
|
|
|
|
# Process multiple packages - split by spaces
|
|
package_urls = args.package_url.split()
|
|
|
|
for package_url in package_urls:
|
|
# Split each package entry using the format pkg_name:url
|
|
if ":" not in package_url:
|
|
raise ValueError(
|
|
f"Invalid format for {package_url}. Expected 'pkg_name:url'"
|
|
)
|
|
|
|
pkg_name, pkg_url = package_url.split(":", 1)
|
|
pkg_name = pkg_name.strip()
|
|
pkg_url = pkg_url.strip()
|
|
parsed_package_urls[pkg_name] = pkg_url
|
|
|
|
return parsed_package_urls
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate a list of installed Python packages and their license information."
|
|
)
|
|
parser.add_argument("--config-path", default=".", help="Path to pyproject.toml")
|
|
args, _ = parser.parse_known_args()
|
|
toml_config = read_pyproject_config(args.config_path)
|
|
logger.info(toml_config)
|
|
args = parse_arguments(toml_config)
|
|
|
|
ignore_list = (
|
|
[pkg.strip() for pkg in args.ignore_packages] if args.ignore_packages else []
|
|
)
|
|
allow_only = (
|
|
[lic.strip() for lic in args.allow_only.split(";")] if args.allow_only else None
|
|
)
|
|
|
|
package_urls = handle_package_urls(args)
|
|
packages = get_installed_packages()
|
|
license_text, notice_text = format_plain_vertical(
|
|
packages,
|
|
package_urls,
|
|
args.license_source or "mixed",
|
|
args.with_license_file,
|
|
args.with_notice_file,
|
|
args.with_urls,
|
|
allow_only,
|
|
ignore_list,
|
|
)
|
|
|
|
if args.output_path:
|
|
license_path = os.path.join(args.output_path, "LICENSE.txt")
|
|
notice_path = os.path.join(args.output_path, "NOTICE.txt")
|
|
|
|
if license_text:
|
|
with open(license_path, "w", encoding="utf-8") as f:
|
|
f.write(license_text)
|
|
if notice_text:
|
|
with open(notice_path, "w", encoding="utf-8") as f:
|
|
f.write(notice_text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|