Source code for pynguin.cli

#  This file is part of Pynguin.
#
#  SPDX-FileCopyrightText: 2019–2024 Pynguin Contributors
#
#  SPDX-License-Identifier: MIT
#
"""Pynguin is an automated unit test generation framework for Python.

This module provides the main entry location for the program execution from the command
line.
"""
from __future__ import annotations

import logging
import os
import sys

from pathlib import Path
from typing import TYPE_CHECKING

import simple_parsing

from rich.console import Console
from rich.logging import RichHandler
from rich.traceback import install

import pynguin.configuration as config

from pynguin.__version__ import __version__
from pynguin.generator import run_pynguin
from pynguin.generator import set_configuration


if TYPE_CHECKING:
    import argparse


def _create_argument_parser() -> argparse.ArgumentParser:
    parser = simple_parsing.ArgumentParser(
        add_option_string_dash_variants=simple_parsing.DashVariant.UNDERSCORE_AND_DASH,
        description="Pynguin is an automatic unit test generation framework for Python",
        fromfile_prefix_chars="@",
    )
    parser.add_argument(
        "--version", action="version", version="%(prog)s " + __version__
    )
    parser.add_argument(
        "-v",
        "--verbose",
        action="count",
        dest="verbosity",
        default=0,
        help="verbose output (repeat for increased verbosity)",
    )
    parser.add_argument(
        "--no-rich",
        "--no_rich",
        "--poor",  # hehe
        dest="no_rich",
        action="store_true",
        default=False,
        help="Don't use rich for nicer consoler output.",
    )
    parser.add_arguments(config.Configuration, dest="config")

    return parser


def _expand_arguments_if_necessary(arguments: list[str]) -> list[str]:
    """Expand command-line arguments, if necessary.

    This is a hacky way to pass comma separated output variables.  The reason to have
    this is an issue with the automatically-generated bash scripts for Pynguin cluster
    execution, for which I am not able to solve the (I assume) globbing issues.  This
    function allows to provide the output variables either separated by spaces or by
    commas, which works as a work-around for the aforementioned problem.

    This function replaces the commas for the ``--output-variables`` parameter and
    the ``--coverage-metrics`` by spaces that can then be handled by the argument-
    parsing code.

    Args:
        arguments: The list of command-line arguments
    Returns:
        The (potentially) processed list of command-line arguments
    """
    if (
        "--output_variables" not in arguments
        and "--output-variables" not in arguments
        and "--coverage_metrics" not in arguments
        and "--coverage-metrics" not in arguments
    ):
        return arguments
    if "--output_variables" in arguments:
        arguments = _parse_comma_separated_option(arguments, "--output_variables")
    elif "--output-variables" in arguments:
        arguments = _parse_comma_separated_option(arguments, "--output-variables")

    if "--coverage_metrics" in arguments:
        arguments = _parse_comma_separated_option(arguments, "--coverage_metrics")
    elif "--coverage-metrics" in arguments:
        arguments = _parse_comma_separated_option(arguments, "--coverage-metrics")
    return arguments


def _parse_comma_separated_option(arguments: list[str], option: str) -> list[str]:
    index = arguments.index(option)
    if "," not in arguments[index + 1]:
        return arguments
    variables = arguments[index + 1].split(",")
    return arguments[: index + 1] + variables + arguments[index + 2 :]


def _setup_output_path(output_path: str) -> None:
    path = Path(output_path).resolve()
    if not path.exists():
        path.mkdir(parents=True, exist_ok=True)


def _setup_logging(verbosity: int, no_rich: bool) -> Console | None:  # noqa: FBT001
    level = logging.WARNING
    if verbosity == 1:
        level = logging.INFO
    if verbosity >= 2:
        level = logging.DEBUG

    console = None
    if no_rich:
        handler: logging.Handler = logging.StreamHandler()
    else:
        install()
        console = Console(tab_size=4)
        handler = RichHandler(
            rich_tracebacks=True, log_time_format="[%X]", console=console
        )
        handler.setFormatter(logging.Formatter("%(message)s"))

    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s]"
        "(%(name)s:%(funcName)s:%(lineno)d): %(message)s",
        datefmt="[%X]",
        handlers=[handler],
    )
    return console


# People may wipe their disk, so we give them a heads-up.
_DANGER_ENV = "PYNGUIN_DANGER_AWARE"


[docs] def main(argv: list[str] | None = None) -> int: """Entry point for the CLI of the Pynguin automatic unit test generation framework. This method behaves like a standard UNIX command-line application, i.e., the return value `0` signals a successful execution. Any other return value signals some errors. This is, e.g., the case if the framework was not able to generate one successfully running test case for the class under test. Args: argv: List of command-line arguments Returns: An integer representing the success of the program run. 0 means success, all non-zero exit codes indicate errors. """ if _DANGER_ENV not in os.environ: print( # noqa: T201 f"""Environment variable '{_DANGER_ENV}' not set. Aborting to avoid harming your system. Please refer to the documentation (https://pynguin.readthedocs.io/en/latest/user/quickstart.html) to see why this happens and what you must do to prevent it.""" ) return -1 if argv is None: argv = sys.argv if len(argv) <= 1: argv.append("--help") argv = _expand_arguments_if_necessary(argv[1:]) argument_parser = _create_argument_parser() parsed = argument_parser.parse_args(argv) _setup_output_path(parsed.config.test_case_output.output_path) console = _setup_logging(parsed.verbosity, parsed.no_rich) set_configuration(parsed.config) if console is not None: with console.status("Running Pynguin..."): return run_pynguin().value else: return run_pynguin().value
if __name__ == "__main__": sys.exit(main(sys.argv))