Files
Giacomo Vercesi a68c53cbb0 pypeline.cli: drop PypeGroup
The `PypeGroup` click group did not provide any real benefit:
* It added extra help about pipebox arguments, but text that was not
  correct from where the help was coming from (the group does not parse
  the arguments). The subcommands still reports it though.
* It forbid using non-PypeGroup and non-PypeCommand commands in the
  command tree, but in the long run this is desirable so it should be
  dropped
For these reasons it has been dropped.
2026-05-22 09:04:35 +02:00

196 lines
6.5 KiB
Python

#
# This file is distributed under the MIT License. See LICENSE.md for details.
#
from typing import IO
import click
from revng.pypeline.analysis import Analysis
from revng.pypeline.cli.common_options import list_objects_option
from revng.pypeline.cli.utils import build_arg_objects, build_help_text, compute_objects
from revng.pypeline.cli.utils import normalize_whitespace
from revng.pypeline.cli.wrappers import WrappablePypeCommand, exec_wrapper_if_needed
from revng.pypeline.container import ContainerDeclaration
from revng.pypeline.model import Model, ReadOnlyModel
from revng.pypeline.object import ObjectSet
from revng.pypeline.task.task import TaskArgument, TaskArgumentAccess
from revng.pypeline.utils.logger import pypeline_logger
from revng.pypeline.utils.registry import get_registry, get_singleton
class RunAnalysisGroup(click.Group):
"""We need to create a custom command for each analysis we loaded from the registry.
Since we already have to generate the code dynamically, we do it lazily so
we generate only the commands that are requested."""
@property
def registry(self) -> dict[str, type[Analysis]]:
return get_registry(Analysis) # type: ignore[type-abstract]
def list_commands(self, ctx):
base = super().list_commands(ctx)
return base + sorted(self.registry.keys())
def get_command(self, ctx, cmd_name):
if cmd_name in self.registry:
return self._build_analysis_command(cmd_name)
return super().get_command(ctx, cmd_name)
def _build_analysis_command(self, analysis_name: str):
"""Dynamically create a command for running an analysis."""
analysis_type: type[Analysis] = self.registry[analysis_name]
if analysis_type.__doc__:
help_text = click.wrap_text(f"\n{normalize_whitespace(analysis_type.__doc__)}")
else:
help_text = f"Run the analysis: {analysis_name}"
help_text = build_help_text(
prologue=help_text,
args=[
TaskArgument(
name=container_type.name,
container_type=container_type,
access=TaskArgumentAccess.READ,
help_text=normalize_whitespace(container_type.__doc__ or ""),
)
for container_type in analysis_type.signature()
],
)
# Build the actual function that will be the command
run_analysis_command = build_run_analysis_command(
analysis_name=analysis_name,
help_text=help_text,
analysis_type=analysis_type,
model_type=get_singleton(Model), # type: ignore[type-abstract]
)
config = getattr(
analysis_type,
"configuration_help",
f'Configuration for the analysis "{analysis_name}".',
)
run_analysis_command = click.option(
"-c",
"--configuration",
type=str,
default="",
help=normalize_whitespace(config),
)(run_analysis_command)
# For each argument, call the `click.argument` decorator to dynamically add
# them to the command
for arg in analysis_type.signature():
run_analysis_command = click.argument(
arg.name,
type=click.Path(exists=True, dir_okay=False, readable=True),
)(run_analysis_command)
run_analysis_command = build_arg_objects(
ContainerDeclaration(
name=arg.name,
container_type=arg,
)
)(run_analysis_command)
return run_analysis_command
def build_run_analysis_command(
analysis_name: str,
help_text: str,
analysis_type: type[Analysis],
model_type: type[Model],
):
@click.command(
cls=WrappablePypeCommand,
name=analysis_name,
help=help_text,
)
@click.option(
"-o",
"output_file",
type=click.File("wb"),
help=(
"Path to write the changed model to, if not specified, the "
"result will be printed to stdout."
),
default="-",
)
@click.argument(
"model",
type=click.Path(exists=True, dir_okay=False, readable=True),
required=True,
)
@list_objects_option
@exec_wrapper_if_needed
def run_analysis_command(
model: str,
configuration: str,
output_file: IO[bytes],
**kwargs,
) -> None:
pypeline_logger.debug_log(f'Running analysis: "{analysis_name}"')
pypeline_logger.debug_log(f'configuration: "{configuration}"')
pypeline_logger.debug_log(f'model: "{model}"')
pypeline_logger.debug_log(f'and kwargs: "{kwargs}"')
analysis = analysis_type()
# Load the model
loaded_model: Model = model_type()
with open(model, "rb") as model_file:
loaded_model = model_type.deserialize(model_file.read())[0]
pypeline_logger.debug_log(f'Model loaded: "{loaded_model}"')
# Load the containers with args form the command line
containers = []
for arg in analysis.signature():
arg_name = arg.name
# Click automatically makes the argument uppercase and make the variable lowercase
path = kwargs[arg_name.lower()]
container = arg.from_file(path)
pypeline_logger.debug_log(
f'Loaded container from "{path}" for argument "{arg_name}": "{container}"'
)
containers.append(container)
# Compute the requests for the incoming containers of the
# analysis
incoming: list[ObjectSet] = []
for arg in analysis.signature():
# If the argument is writable, we need to request the objects
incoming.append(
compute_objects(
model=ReadOnlyModel(loaded_model),
arg_name=arg.name,
kind=arg.kind,
kwargs=kwargs,
)
)
# Disable caching
loaded_model.disable_caching()
# Finally, run the analysis
analysis.run(
model=loaded_model,
containers=containers,
incoming=incoming,
configuration=configuration,
)
pypeline_logger.debug_log("Analysis run completed")
# Output the modified model
output_file.write(loaded_model.serialize())
return run_analysis_command
@click.group(
cls=RunAnalysisGroup,
help="Run an analysis",
)
def run_analysis() -> None:
pass