import datetime
import json
import os
from typing import Optional, TYPE_CHECKING, Union, TypedDict, List, Any
from pip_rating import __version__
from rich.console import Console
from rich.progress import (
Progress,
TaskID,
TaskProgressColumn,
TextColumn,
BarColumn,
TimeRemainingColumn,
)
from rich.status import Status
from rich.table import Table
from rich.tree import Tree
if TYPE_CHECKING:
from pip_rating.packages import Package
from pip_rating.rating import ScoreBase
from pip_rating.dependencies import Dependencies
MIN_PACKAGE_NAME = 15
FORMATS = ["text", "tree", "json", "only-rating", "badge"]
BADGE_FLAT_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82"
height="20" role="img" aria-label="pip-rating: {letter}">
<title>pip-rating: {letter}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="82" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="65" height="20" fill="#555"/><rect x="65" width="17" height="20" fill="{bgcolor}"/>
<rect width="82" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
text-rendering="geometricPrecision" font-size="110">
<text aria-hidden="true" x="335" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="550">
pip-rating
</text>
<text x="335" y="140" transform="scale(.1)" fill="#fff" textLength="550">pip-rating</text>
<text aria-hidden="true" x="725" y="150" fill="{shcolor}" fill-opacity=".3" transform="scale(.1)"
textLength="70">
{letter}
</text>
<text x="725" y="140" transform="scale(.1)" fill="{color}" textLength="70">{letter}</text>
</g>
</svg>
"""
BADGE_FLAT_SQUARE_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="82" height="20" role="img"
aria-label="pip-rating: {letter}">
<title>pip-rating: {letter}</title>
<g shape-rendering="crispEdges">
<rect width="65" height="20" fill="#555"/><rect x="65" width="17" height="20" fill="{bgcolor}"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
text-rendering="geometricPrecision" font-size="110">
<text x="335" y="140" transform="scale(.1)" fill="#fff" textLength="550">pip-rating</text>
<text x="725" y="140" transform="scale(.1)" fill="{color}" textLength="70">{letter}</text>
</g>
</svg>
"""
BADGE_FOR_THE_BADGE_SVG = """\
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128.75" height="28" role="img"
aria-label="PIP-RATING: {letter}">
<title>PIP-RATING: {letter}</title>
<g shape-rendering="crispEdges">
<rect width="96.5" height="28" fill="#555"/>
<rect x="96.5" width="32.25" height="28" fill="{bgcolor}"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
text-rendering="geometricPrecision" font-size="100">
<text transform="scale(.1)" x="482.5" y="175"
textLength="725" fill="#fff">
PIP-RATING
</text>
<text transform="scale(.1)" x="1126.25" y="175" textLength="82.5" fill="{color}"
font-weight="bold">
{letter}
</text>
</g>
</svg>
"""
PIP_RATING_BADGE_COLORS = {
"S": os.environ.get("PIP_RATING_BADGE_S_COLOR") or "#007EC6",
"A": os.environ.get("PIP_RATING_BADGE_A_COLOR") or "#44CC11",
"B": os.environ.get("PIP_RATING_BADGE_B_COLOR") or "#97CA00",
"C": os.environ.get("PIP_RATING_BADGE_C_COLOR") or "#FFD700",
"D": os.environ.get("PIP_RATING_BADGE_D_COLOR") or "#FFAF00",
"E": os.environ.get("PIP_RATING_BADGE_E_COLOR") or "#FF5F00",
"F": os.environ.get("PIP_RATING_BADGE_F_COLOR") or "#E05D44",
}
PIP_RATING_BADGES = {
"flat": BADGE_FLAT_SVG,
"flat-square": BADGE_FLAT_SQUARE_SVG,
"for-the-badge": BADGE_FOR_THE_BADGE_SVG,
}
PIP_RATING_BADGE_DEFAULT_STYLE = "flat"
PIP_RATING_BADGE_STYLE = os.environ.get(
"PIP_RATING_BADGE_STYLE", PIP_RATING_BADGE_DEFAULT_STYLE
)
[docs]def get_luminance(hex_color: str) -> float:
"""Get the luminance of a color as float."""
color = hex_color[1:]
hex_red = int(color[0:2], base=16)
hex_green = int(color[2:4], base=16)
hex_blue = int(color[4:6], base=16)
return hex_red * 0.2126 + hex_green * 0.7152 + hex_blue * 0.0722
[docs]def colorize_score(score: Union["ScoreBase", int]) -> str:
"""Colorize the score."""
if int(score) < 0:
return f"[bold red]{score}[/bold red]"
if int(score) > 0:
return f"[bold green]+{score}[/bold green]"
return f"[bold bright_black]{score}[/bold bright_black]"
[docs]def colorize_rating(score: Union["ScoreBase", int]) -> "RatingLetter":
"""Colorize the rating."""
for rating_letter in RATING_LETTERS:
if max(0, int(score)) >= rating_letter.score:
return rating_letter
[docs]def colorize_rating_package(
package: "Package", parent_package: Optional["Package"] = None
) -> str:
"""Colorize the rating of the package."""
colorized_rating = colorize_rating(package.rating.get_rating_score(parent_package))
colorized_global_rating = colorize_rating(
package.rating.get_global_rating_score(parent_package)
)
if colorized_rating > colorized_global_rating:
return f"{colorized_rating} -> {colorized_global_rating}"
else:
return f"{colorized_rating}"
[docs]def add_tree_node(
dependencies: "Dependencies",
tree: "Tree",
package: "Package",
parent_package: Optional["Package"] = None,
):
if parent_package is None:
tree = tree.add(
f"[bold]:package: {package.name} ({colorize_rating_package(package)})[/bold]"
)
for child in package.get_node_from_parent(parent_package).children:
if child.name not in dependencies.packages:
continue
subpackage = dependencies.packages[child.name]
subtree_package = tree.add(
f"[bold]{child.name} ({colorize_rating_package(subpackage, package)})[/bold]"
)
add_tree_node(dependencies, subtree_package, subpackage, package)
[docs]class RatingLetter:
"""Rating letter."""
def __init__(self, letter: str, score: int, color: str):
self.letter = letter
self.score = score
self.color = color
def __lt__(self, other):
return self.score < other.score
def __gt__(self, other):
return self.score > other.score
def __le__(self, other):
return self.score <= other.score
def __ge__(self, other):
return self.score >= other.score
def __eq__(self, other):
return self.score == other.score
def __ne__(self, other):
return self.score != other.score
def __str__(self) -> str:
return f"[bold {self.color}]{self.letter}[/bold {self.color}]"
def __repr__(self) -> str:
return f"<RatingLetter {self.letter}>"
RATING_LETTERS = [
RatingLetter("S", 30, "bright_cyan"),
RatingLetter("A", 25, "green3"),
RatingLetter("B", 20, "green_yellow"),
RatingLetter("C", 15, "gold1"),
RatingLetter("D", 10, "orange1"),
RatingLetter("E", 5, "orange_red1"),
RatingLetter("F", 0, "bright_red"),
]
[docs]class JsonResults(TypedDict):
"""JSON results"""
requirements: List[str]
updated_at: str
schema_version: str
global_rating_letter: str
global_rating_score: int
packages: List[dict]
[docs]class Results:
"""Print pip-ratings results to the terminal."""
_status: Optional[Status]
progress: Optional[Progress]
task: Optional[TaskID]
def __init__(self, to_file: Optional[str] = None):
results_file = None
if to_file:
results_file = open(to_file, "w")
self.progress_console = Console(stderr=True)
self.progress_console.status("[bold green]Loading...")
self.results_console = Console(file=results_file)
self._status = None
self.progress = None
self.task = None
self.to_file = to_file
@property
def status(self) -> Optional[Status]:
if not self._status:
self._status = self.progress_console.status("[bold green]Waiting...")
self._status.start()
return self._status
[docs] def processing_package(self, package: Any):
self.status.update(f"Processing package [bold green]{package}[/bold green]...")
[docs] def analizing_package(self, package: str, total: int):
if self._status:
self._status.stop()
if not self.progress:
self.progress = Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(complete_style="blue"),
TaskProgressColumn(),
TimeRemainingColumn(),
console=self.progress_console,
)
self.task = self.progress.add_task("Analizing packages...", total=total)
self.progress.start()
spaces = " " * (MIN_PACKAGE_NAME - len(package))
self.progress.update(
self.task,
description=f"Analizing package [bold blue]{package}[/bold blue]..."
+ spaces,
advance=1,
refresh=True,
)
[docs] def get_global_rating_score(self, dependencies: "Dependencies") -> int:
global_rating_score = dependencies.get_global_rating_score()
if self.progress:
self.progress.update(
self.task,
description="[bold green]Analyzed all packages[/bold green]",
refresh=True,
)
self.progress.stop()
return global_rating_score
[docs] def show_results(self, dependencies: "Dependencies", format_name: str = "text"):
"""Show results depending on the format. Optionally save to a file.
:param dependencies: Dependencies
:param format_name: Format name. Choices: FORMATS
"""
if format_name == "text":
self.show_packages_results(dependencies)
elif format_name == "tree":
self.show_tree_results(dependencies)
elif format_name == "json":
self.show_json_results(dependencies)
elif format_name == "only-rating":
self.show_only_rating_results(dependencies)
elif format_name == "badge":
self.show_badge_results(dependencies)
else:
raise ValueError(f"Format name must be one of {', '.join(FORMATS)}")
[docs] def show_packages_results(self, dependencies: "Dependencies"):
global_rating_score = self.get_global_rating_score(dependencies)
for package in dependencies.packages.values():
if package.name not in dependencies.req_file:
continue
package_global_rating_score = package.rating.get_global_rating_score()
package_global_rating_score_letter = colorize_rating(
package_global_rating_score
)
rating_score_letter = colorize_rating(package.rating.get_rating_score())
vulnerabilities = []
if not package_global_rating_score:
vulnerabilities = package.rating.get_vulnerabilities()
if (
vulnerabilities
or package_global_rating_score_letter >= rating_score_letter
):
print_score = f"{package_global_rating_score_letter}"
else:
print_score = (
f"{rating_score_letter} -> {package_global_rating_score_letter}"
)
self.results_console.print(
f":package: Package [bold blue]{package.name}[/bold blue]: "
+ print_score
)
for key, value in package.rating.breakdown_scores:
key = (
key.split(".")[-1]
.replace("iso_dt", "")
.replace("_", " ")
.capitalize()
)
self.results_console.print(
f" :black_medium-small_square: {key}: {colorize_score(value)}"
)
if package_global_rating_score < package.rating.rating_score:
low_rating_dependences = [
f"{pkg.name} ({colorize_rating(score)})"
for pkg, score in package.rating.descendant_rating_scores
if colorize_rating(score) < rating_score_letter
]
self.results_console.print(
f" :arrow_lower_right: Low rating dependencies: {', '.join(low_rating_dependences)}"
)
if vulnerabilities:
self.results_console.print(
f" :biohazard: Vulnerabilities found: [bold grey53]"
f"{', '.join([vuln['id'] for vuln in vulnerabilities])}[/bold grey53]"
)
self.results_console.print("")
self.results_console.print("")
table = Table(show_header=False)
table.add_row(f"Global rating score: {colorize_rating(global_rating_score)}")
self.results_console.print(table)
[docs] def show_tree_results(self, dependencies: "Dependencies"):
global_rating_score = self.get_global_rating_score(dependencies)
req_file_name = (
str(dependencies.req_file) if dependencies.req_file else "Packages list"
)
tree = Tree(
f"[bold]{req_file_name} ({colorize_rating(global_rating_score)})[/bold]"
)
for package in dependencies.packages.values():
if package.name not in dependencies.req_file:
continue
add_tree_node(dependencies, tree, package)
self.results_console.print(tree)
[docs] def get_json_results(self, dependencies: "Dependencies"):
global_rating_score = self.get_global_rating_score(dependencies)
packages = [
package
for package in dependencies.packages.values()
if package.name in dependencies.req_file
]
return {
"requirements": dependencies.req_file,
"updated_at": datetime.datetime.now().isoformat(),
"schema_version": __version__,
"global_rating_letter": colorize_rating(global_rating_score).letter,
"global_rating_score": global_rating_score,
"packages": [package.as_json() for package in packages],
}
[docs] def show_json_results(self, dependencies: "Dependencies"):
results = self.get_json_results(dependencies)
if self.to_file:
with open(self.to_file, "w") as file:
json.dump(results, file, indent=4, sort_keys=False)
else:
print(json.dumps(results, indent=4, sort_keys=False))
[docs] def show_only_rating_results(self, dependencies: "Dependencies"):
global_rating_score = self.get_global_rating_score(dependencies)
self.results_console.print(f"{colorize_rating(global_rating_score)}")
[docs] def show_badge_results(self, dependencies: "Dependencies") -> None:
"""Show the badge depending on the global rating score.
The badge is printed as a svg image
:param dependencies: Dependencies
"""
letter = colorize_rating(self.get_global_rating_score(dependencies)).letter
badge = PIP_RATING_BADGES.get(
PIP_RATING_BADGE_STYLE, PIP_RATING_BADGE_DEFAULT_STYLE
)
badge_color = PIP_RATING_BADGE_COLORS[letter]
luminance = get_luminance(badge_color)
if luminance < 180:
color = "fff"
shcolor = "#010101"
else:
color = "#333"
shcolor = "#ccc"
self.results_console.print(
badge.format(
letter=letter,
bgcolor=badge_color,
color=color,
shcolor=shcolor,
)
)