Module cli_tool_audit.views

Main output view for cli_tool_audit assuming tool list is in config.

Expand source code
"""
Main output view for cli_tool_audit assuming tool list is in config.
"""
import concurrent
import json
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Lock
from typing import Optional, Union

import colorama
from prettytable import PrettyTable
from prettytable.colortable import ColorTable, Themes
from tqdm import tqdm

import cli_tool_audit.call_and_compatible as call_and_compatible
import cli_tool_audit.config_reader as config_reader
import cli_tool_audit.json_utils as json_utils
import cli_tool_audit.models as models
import cli_tool_audit.policy as policy

colorama.init(convert=True)

logger = logging.getLogger(__name__)


def validate(
    file_path: Path = Path("pyproject.toml"),
    no_cache: bool = False,
    tags: Optional[list[str]] = None,
    disable_progress_bar: bool = False,
) -> list[models.ToolCheckResult]:
    """
    Validate the tools in the pyproject.toml file.

    Args:
        file_path (Path, optional): The path to the pyproject.toml file. Defaults to "pyproject.toml".
        no_cache (bool, optional): If True, don't use the cache. Defaults to False.
        tags (Optional[list[str]], optional): Only check tools with these tags. Defaults to None.
        disable_progress_bar (bool, optional): If True, disable the progress bar. Defaults to False.

    Returns:
        list[models.ToolCheckResult]: A list of ToolCheckResult objects.
    """
    if tags is None:
        tags = []
    cli_tools = config_reader.read_config(file_path)
    return process_tools(cli_tools, no_cache, tags, disable_progress_bar=disable_progress_bar)


def process_tools(
    cli_tools: dict[str, models.CliToolConfig],
    no_cache: bool = False,
    tags: Optional[list[str]] = None,
    disable_progress_bar: bool = False,
) -> list[models.ToolCheckResult]:
    """
    Process the tools from a dictionary of CliToolConfig objects.

    Args:
        cli_tools (dict[str, models.CliToolConfig]): A dictionary of tool names and CliToolConfig objects.
        no_cache (bool, optional): If True, don't use the cache. Defaults to False.
        tags (Optional[list[str]], optional): Only check tools with these tags. Defaults to None.
        disable_progress_bar (bool, optional): If True, disable the progress bar. Defaults to False.

    Returns:
        list[models.ToolCheckResult]: A list of ToolCheckResult objects.
    """
    if tags:
        print(tags)
        cli_tools = {
            tool: config
            for tool, config in cli_tools.items()
            if config.tags and any(tag in config.tags for tag in tags)
        }

    # Determine the number of available CPUs
    num_cpus = os.cpu_count()

    enable_cache = len(cli_tools) >= 5
    # Create a ThreadPoolExecutor with one thread per CPU

    if no_cache:
        enable_cache = False
    lock = Lock()
    # Threaded appears faster.
    # lock = Dummy()
    # with ProcessPoolExecutor(max_workers=num_cpus) as executor:
    with ThreadPoolExecutor(max_workers=num_cpus) as executor:
        disable = should_show_progress_bar(cli_tools)
        with tqdm(total=len(cli_tools), disable=disable) as pbar:
            # Submit tasks to the executor
            futures = [
                executor.submit(call_and_compatible.check_tool_wrapper, (tool, config, lock, enable_cache))
                for tool, config in cli_tools.items()
            ]
            results = []
            for future in concurrent.futures.as_completed(futures):
                result = future.result()
                pbar.update(1)
                results.append(result)
    return results


def report_from_pyproject_toml(
    file_path: Optional[Path] = Path("pyproject.toml"),
    config_as_dict: Optional[dict[str, models.CliToolConfig]] = None,
    exit_code_on_failure: bool = True,
    file_format: str = "table",
    no_cache: bool = False,
    tags: Optional[list[str]] = None,
    only_errors: bool = False,
) -> int:
    """
    Report on the compatibility of the tools in the pyproject.toml file.

    Args:
        file_path (Path, optional): The path to the pyproject.toml file. Defaults to "pyproject.toml".
        config_as_dict (Optional[dict[str, models.CliToolConfig]], optional): A dictionary of tool names and CliToolConfig objects. Defaults to None.
        exit_code_on_failure (bool, optional): If True, exit with return value of 1 if validation fails. Defaults to True.
        file_format (str, optional): The format of the output. Defaults to "table".
        no_cache (bool, optional): If True, don't use the cache. Defaults to False.
        tags (Optional[list[str]], optional): Only check tools with these tags. Defaults to None.
        only_errors (bool, optional): Only show errors. Defaults to False.

    Returns:
        int: The exit code.
    """
    if tags is None:
        tags = []
    if not file_format:
        file_format = "table"

    if config_as_dict:
        results = process_tools(config_as_dict, no_cache, tags, disable_progress_bar=file_format != "table")
    elif file_path:
        # Handle config file searching.
        if not file_path.exists():
            one_up = ".." / file_path
            if one_up.exists():
                file_path = one_up
        results = validate(file_path, no_cache=no_cache, tags=tags, disable_progress_bar=file_format != "table")
    else:
        raise TypeError("Must provide either file_path or config_as_dict.")

    success_and_failure = len(results)
    # Remove success, no action needed.
    if only_errors:
        results = [result for result in results if result.is_problem()]

    failed = policy.apply_policy(results)

    if file_format == "json":
        print(json.dumps([result.__dict__ for result in results], indent=4, default=json_utils.custom_json_serializer))
    elif file_format == "json-compact":
        print(json.dumps([result.__dict__ for result in results], default=json_utils.custom_json_serializer))
    elif file_format == "xml":
        print("<results>")
        for result in results:
            print("  <result>")
            for key, value in result.__dict__.items():
                print(f"    <{key}>{value}</{key}>")
            print("  </result>")
        print("</results>")
    elif file_format == "table":
        table = pretty_print_results(results, truncate_long_versions=True, include_docs=False)
        print(table)
    elif file_format == "csv":
        print(
            "tool,desired_version,is_available,is_snapshot,found_version,parsed_version,is_compatible,is_broken,last_modified"
        )
        for result in results:
            print(
                f"{result.tool},{result.desired_version},{result.is_available},{result.is_snapshot},{result.found_version},{result.parsed_version},{result.is_compatible},{result.is_broken},{result.last_modified}"
            )
    elif file_format == "html":
        table = pretty_print_results(results, truncate_long_versions=False, include_docs=True)
        print(table.get_html_string())
    else:
        print(
            f"Unknown file format: {file_format}, defaulting to table output. Supported formats: json, json-compact, xml, table, csv."
        )
        table = pretty_print_results(results, truncate_long_versions=True, include_docs=False)
        print(table)

    if only_errors and success_and_failure > 0 and len(results) == 0:
        print("No errors found, all tools meet version policy.")
        return 0
    if failed and exit_code_on_failure and file_format == "table":
        print("Did not pass validation, failing with return value of 1.")
        return 1
    return 0


def pretty_print_results(
    results: list[models.ToolCheckResult], truncate_long_versions: bool, include_docs: bool
) -> Union[PrettyTable, ColorTable]:
    """
    Pretty print the results of the validation.

    Args:
        results (list[models.ToolCheckResult]): A list of ToolCheckResult objects.
        truncate_long_versions (bool): If True, truncate long versions. Defaults to False.
        include_docs (bool): If True, include install command and install docs. Defaults to False.

    Returns:
        Union[PrettyTable, ColorTable]: A PrettyTable or ColorTable object.
    """
    if os.environ.get("NO_COLOR") or os.environ.get("CI"):
        table = PrettyTable()
    else:
        table = ColorTable(theme=Themes.OCEAN)
    table.field_names = ["Tool", "Found", "Parsed", "Desired", "Status", "Modified"]
    if include_docs:
        table.field_names.append("Install Command")
        table.field_names.append("Install Docs")

    all_rows: list[list[str]] = []

    for result in results:
        if truncate_long_versions:
            found_version = result.found_version[0:25].strip() if result.found_version else ""
        else:
            found_version = result.found_version or ""

        try:
            last_modified = result.last_modified.strftime("%m/%d/%y") if result.last_modified else ""
        except ValueError:
            last_modified = str(result.last_modified)
        row_data = [
            result.tool,
            found_version or "",
            result.parsed_version if result.parsed_version else "",
            result.desired_version or "",
            # "Yes" if result.is_compatible == "Compatible" else result.is_compatible,
            result.status() or "",
            last_modified,
        ]
        if include_docs:
            row_data.append(result.tool_config.install_command or "")
            row_data.append(result.tool_config.install_docs or "")
        row_transformed = []
        for datum in row_data:
            if result.is_problem():
                transformed = f"{colorama.Fore.RED}{datum}{colorama.Style.RESET_ALL}"
            else:
                transformed = str(datum)
            row_transformed.append(transformed)
        all_rows.append(row_transformed)

    table.add_rows(sorted(all_rows, key=lambda x: x[0]))

    return table


def should_show_progress_bar(cli_tools) -> Optional[bool]:
    disable = len(cli_tools) < 5 or os.environ.get("CI") or os.environ.get("NO_COLOR")
    return True if disable else None


if __name__ == "__main__":
    report_from_pyproject_toml()

Functions

def pretty_print_results(results: list[ToolCheckResult], truncate_long_versions: bool, include_docs: bool) ‑> Union[prettytable.prettytable.PrettyTable, prettytable.colortable.ColorTable]

Pretty print the results of the validation.

Args

results : list[models.ToolCheckResult]
A list of ToolCheckResult objects.
truncate_long_versions : bool
If True, truncate long versions. Defaults to False.
include_docs : bool
If True, include install command and install docs. Defaults to False.

Returns

Union[PrettyTable, ColorTable]
A PrettyTable or ColorTable object.
Expand source code
def pretty_print_results(
    results: list[models.ToolCheckResult], truncate_long_versions: bool, include_docs: bool
) -> Union[PrettyTable, ColorTable]:
    """
    Pretty print the results of the validation.

    Args:
        results (list[models.ToolCheckResult]): A list of ToolCheckResult objects.
        truncate_long_versions (bool): If True, truncate long versions. Defaults to False.
        include_docs (bool): If True, include install command and install docs. Defaults to False.

    Returns:
        Union[PrettyTable, ColorTable]: A PrettyTable or ColorTable object.
    """
    if os.environ.get("NO_COLOR") or os.environ.get("CI"):
        table = PrettyTable()
    else:
        table = ColorTable(theme=Themes.OCEAN)
    table.field_names = ["Tool", "Found", "Parsed", "Desired", "Status", "Modified"]
    if include_docs:
        table.field_names.append("Install Command")
        table.field_names.append("Install Docs")

    all_rows: list[list[str]] = []

    for result in results:
        if truncate_long_versions:
            found_version = result.found_version[0:25].strip() if result.found_version else ""
        else:
            found_version = result.found_version or ""

        try:
            last_modified = result.last_modified.strftime("%m/%d/%y") if result.last_modified else ""
        except ValueError:
            last_modified = str(result.last_modified)
        row_data = [
            result.tool,
            found_version or "",
            result.parsed_version if result.parsed_version else "",
            result.desired_version or "",
            # "Yes" if result.is_compatible == "Compatible" else result.is_compatible,
            result.status() or "",
            last_modified,
        ]
        if include_docs:
            row_data.append(result.tool_config.install_command or "")
            row_data.append(result.tool_config.install_docs or "")
        row_transformed = []
        for datum in row_data:
            if result.is_problem():
                transformed = f"{colorama.Fore.RED}{datum}{colorama.Style.RESET_ALL}"
            else:
                transformed = str(datum)
            row_transformed.append(transformed)
        all_rows.append(row_transformed)

    table.add_rows(sorted(all_rows, key=lambda x: x[0]))

    return table
def process_tools(cli_tools: dict[str, CliToolConfig], no_cache: bool = False, tags: Optional[list[str]] = None, disable_progress_bar: bool = False) ‑> list[ToolCheckResult]

Process the tools from a dictionary of CliToolConfig objects.

Args

cli_tools : dict[str, models.CliToolConfig]
A dictionary of tool names and CliToolConfig objects.
no_cache : bool, optional
If True, don't use the cache. Defaults to False.
tags : Optional[list[str]], optional
Only check tools with these tags. Defaults to None.
disable_progress_bar : bool, optional
If True, disable the progress bar. Defaults to False.

Returns

list[models.ToolCheckResult]
A list of ToolCheckResult objects.
Expand source code
def process_tools(
    cli_tools: dict[str, models.CliToolConfig],
    no_cache: bool = False,
    tags: Optional[list[str]] = None,
    disable_progress_bar: bool = False,
) -> list[models.ToolCheckResult]:
    """
    Process the tools from a dictionary of CliToolConfig objects.

    Args:
        cli_tools (dict[str, models.CliToolConfig]): A dictionary of tool names and CliToolConfig objects.
        no_cache (bool, optional): If True, don't use the cache. Defaults to False.
        tags (Optional[list[str]], optional): Only check tools with these tags. Defaults to None.
        disable_progress_bar (bool, optional): If True, disable the progress bar. Defaults to False.

    Returns:
        list[models.ToolCheckResult]: A list of ToolCheckResult objects.
    """
    if tags:
        print(tags)
        cli_tools = {
            tool: config
            for tool, config in cli_tools.items()
            if config.tags and any(tag in config.tags for tag in tags)
        }

    # Determine the number of available CPUs
    num_cpus = os.cpu_count()

    enable_cache = len(cli_tools) >= 5
    # Create a ThreadPoolExecutor with one thread per CPU

    if no_cache:
        enable_cache = False
    lock = Lock()
    # Threaded appears faster.
    # lock = Dummy()
    # with ProcessPoolExecutor(max_workers=num_cpus) as executor:
    with ThreadPoolExecutor(max_workers=num_cpus) as executor:
        disable = should_show_progress_bar(cli_tools)
        with tqdm(total=len(cli_tools), disable=disable) as pbar:
            # Submit tasks to the executor
            futures = [
                executor.submit(call_and_compatible.check_tool_wrapper, (tool, config, lock, enable_cache))
                for tool, config in cli_tools.items()
            ]
            results = []
            for future in concurrent.futures.as_completed(futures):
                result = future.result()
                pbar.update(1)
                results.append(result)
    return results
def report_from_pyproject_toml(file_path: Optional[pathlib.Path] = WindowsPath('pyproject.toml'), config_as_dict: Optional[dict[str, CliToolConfig]] = None, exit_code_on_failure: bool = True, file_format: str = 'table', no_cache: bool = False, tags: Optional[list[str]] = None, only_errors: bool = False) ‑> int

Report on the compatibility of the tools in the pyproject.toml file.

Args

file_path : Path, optional
The path to the pyproject.toml file. Defaults to "pyproject.toml".
config_as_dict : Optional[dict[str, models.CliToolConfig]], optional
A dictionary of tool names and CliToolConfig objects. Defaults to None.
exit_code_on_failure : bool, optional
If True, exit with return value of 1 if validation fails. Defaults to True.
file_format : str, optional
The format of the output. Defaults to "table".
no_cache : bool, optional
If True, don't use the cache. Defaults to False.
tags : Optional[list[str]], optional
Only check tools with these tags. Defaults to None.
only_errors : bool, optional
Only show errors. Defaults to False.

Returns

int
The exit code.
Expand source code
def report_from_pyproject_toml(
    file_path: Optional[Path] = Path("pyproject.toml"),
    config_as_dict: Optional[dict[str, models.CliToolConfig]] = None,
    exit_code_on_failure: bool = True,
    file_format: str = "table",
    no_cache: bool = False,
    tags: Optional[list[str]] = None,
    only_errors: bool = False,
) -> int:
    """
    Report on the compatibility of the tools in the pyproject.toml file.

    Args:
        file_path (Path, optional): The path to the pyproject.toml file. Defaults to "pyproject.toml".
        config_as_dict (Optional[dict[str, models.CliToolConfig]], optional): A dictionary of tool names and CliToolConfig objects. Defaults to None.
        exit_code_on_failure (bool, optional): If True, exit with return value of 1 if validation fails. Defaults to True.
        file_format (str, optional): The format of the output. Defaults to "table".
        no_cache (bool, optional): If True, don't use the cache. Defaults to False.
        tags (Optional[list[str]], optional): Only check tools with these tags. Defaults to None.
        only_errors (bool, optional): Only show errors. Defaults to False.

    Returns:
        int: The exit code.
    """
    if tags is None:
        tags = []
    if not file_format:
        file_format = "table"

    if config_as_dict:
        results = process_tools(config_as_dict, no_cache, tags, disable_progress_bar=file_format != "table")
    elif file_path:
        # Handle config file searching.
        if not file_path.exists():
            one_up = ".." / file_path
            if one_up.exists():
                file_path = one_up
        results = validate(file_path, no_cache=no_cache, tags=tags, disable_progress_bar=file_format != "table")
    else:
        raise TypeError("Must provide either file_path or config_as_dict.")

    success_and_failure = len(results)
    # Remove success, no action needed.
    if only_errors:
        results = [result for result in results if result.is_problem()]

    failed = policy.apply_policy(results)

    if file_format == "json":
        print(json.dumps([result.__dict__ for result in results], indent=4, default=json_utils.custom_json_serializer))
    elif file_format == "json-compact":
        print(json.dumps([result.__dict__ for result in results], default=json_utils.custom_json_serializer))
    elif file_format == "xml":
        print("<results>")
        for result in results:
            print("  <result>")
            for key, value in result.__dict__.items():
                print(f"    <{key}>{value}</{key}>")
            print("  </result>")
        print("</results>")
    elif file_format == "table":
        table = pretty_print_results(results, truncate_long_versions=True, include_docs=False)
        print(table)
    elif file_format == "csv":
        print(
            "tool,desired_version,is_available,is_snapshot,found_version,parsed_version,is_compatible,is_broken,last_modified"
        )
        for result in results:
            print(
                f"{result.tool},{result.desired_version},{result.is_available},{result.is_snapshot},{result.found_version},{result.parsed_version},{result.is_compatible},{result.is_broken},{result.last_modified}"
            )
    elif file_format == "html":
        table = pretty_print_results(results, truncate_long_versions=False, include_docs=True)
        print(table.get_html_string())
    else:
        print(
            f"Unknown file format: {file_format}, defaulting to table output. Supported formats: json, json-compact, xml, table, csv."
        )
        table = pretty_print_results(results, truncate_long_versions=True, include_docs=False)
        print(table)

    if only_errors and success_and_failure > 0 and len(results) == 0:
        print("No errors found, all tools meet version policy.")
        return 0
    if failed and exit_code_on_failure and file_format == "table":
        print("Did not pass validation, failing with return value of 1.")
        return 1
    return 0
def should_show_progress_bar(cli_tools) ‑> Optional[bool]
Expand source code
def should_show_progress_bar(cli_tools) -> Optional[bool]:
    disable = len(cli_tools) < 5 or os.environ.get("CI") or os.environ.get("NO_COLOR")
    return True if disable else None
def validate(file_path: pathlib.Path = WindowsPath('pyproject.toml'), no_cache: bool = False, tags: Optional[list[str]] = None, disable_progress_bar: bool = False) ‑> list[ToolCheckResult]

Validate the tools in the pyproject.toml file.

Args

file_path : Path, optional
The path to the pyproject.toml file. Defaults to "pyproject.toml".
no_cache : bool, optional
If True, don't use the cache. Defaults to False.
tags : Optional[list[str]], optional
Only check tools with these tags. Defaults to None.
disable_progress_bar : bool, optional
If True, disable the progress bar. Defaults to False.

Returns

list[models.ToolCheckResult]
A list of ToolCheckResult objects.
Expand source code
def validate(
    file_path: Path = Path("pyproject.toml"),
    no_cache: bool = False,
    tags: Optional[list[str]] = None,
    disable_progress_bar: bool = False,
) -> list[models.ToolCheckResult]:
    """
    Validate the tools in the pyproject.toml file.

    Args:
        file_path (Path, optional): The path to the pyproject.toml file. Defaults to "pyproject.toml".
        no_cache (bool, optional): If True, don't use the cache. Defaults to False.
        tags (Optional[list[str]], optional): Only check tools with these tags. Defaults to None.
        disable_progress_bar (bool, optional): If True, disable the progress bar. Defaults to False.

    Returns:
        list[models.ToolCheckResult]: A list of ToolCheckResult objects.
    """
    if tags is None:
        tags = []
    cli_tools = config_reader.read_config(file_path)
    return process_tools(cli_tools, no_cache, tags, disable_progress_bar=disable_progress_bar)