/ src / archan / cli.py
cli.py
  1  """Module that contains the command line application."""
  2  
  3  # Why does this file exist, and why not put this in `__main__`?
  4  #
  5  # You might be tempted to import things from `__main__` later,
  6  # but that will cause problems: the code will get executed twice:
  7  #
  8  # - When you run `python -m archan` python will execute
  9  #   `__main__.py` as a script. That means there won't be any
 10  #   `archan.__main__` in `sys.modules`.
 11  # - When you import `__main__` it will get executed again (as a module) because
 12  #   there's no `archan.__main__` in `sys.modules`.
 13  
 14  from __future__ import annotations
 15  
 16  import argparse
 17  import logging
 18  import os
 19  import sys
 20  from typing import Any
 21  
 22  import colorama
 23  
 24  from archan import debug
 25  from archan.analysis import Analysis
 26  from archan.config import Config
 27  from archan.logging import Logger
 28  
 29  logger = Logger.get_logger(__name__)
 30  
 31  
 32  class _DebugInfo(argparse.Action):
 33      def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
 34          super().__init__(nargs=nargs, **kwargs)
 35  
 36      def __call__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ARG002
 37          debug.print_debug_info()
 38          sys.exit(0)
 39  
 40  
 41  def valid_file(value: str) -> str:
 42      """Check if given file exists and is a regular file.
 43  
 44      Parameters:
 45          value: Path to the file.
 46  
 47      Raises:
 48          ArgumentTypeError: When value not valid.
 49  
 50      Returns:
 51          Original value argument.
 52      """
 53      if not value:
 54          raise argparse.ArgumentTypeError("'' is not a valid file path")
 55      if not os.path.exists(value):
 56          raise argparse.ArgumentTypeError(f"{value} is not a valid file path")
 57      if os.path.isdir(value):
 58          raise argparse.ArgumentTypeError(f"{value} is a directory, not a regular file")
 59      return value
 60  
 61  
 62  def valid_level(value: str) -> str:
 63      """Validate the logging level argument for the parser.
 64  
 65      Parameters:
 66          value: The value provided on the command line.
 67  
 68      Raises:
 69          ArgumentTypeError: When value not valid.
 70  
 71      Returns:
 72          The validated level.
 73      """
 74      value = value.upper()
 75      if getattr(logging, value, None) is None:
 76          raise argparse.ArgumentTypeError(f"{value} is not a valid level")
 77      return value
 78  
 79  
 80  def get_parser() -> argparse.ArgumentParser:
 81      """Return the CLI argument parser.
 82  
 83      Returns:
 84          An argparse parser.
 85      """
 86      parser = argparse.ArgumentParser(
 87          prog="archan",
 88          add_help=False,
 89          description="Analysis of your architecture strength based on DSM data",
 90      )
 91      parser.add_argument(
 92          "-c",
 93          "--config",
 94          action="store",
 95          type=valid_file,
 96          dest="config_file",
 97          metavar="FILE",
 98          help="Configuration file to use.",
 99      )
100      parser.add_argument(
101          "-h",
102          "--help",
103          action="help",
104          default=argparse.SUPPRESS,
105          help="Show this help message and exit.",
106      )
107      parser.add_argument(
108          "-i",
109          "--input",
110          action="store",
111          type=valid_file,
112          dest="input_file",
113          metavar="FILE",
114          help="Input file containing CSV data.",
115      )
116      parser.add_argument(
117          "-l",
118          "--list-plugins",
119          action="store_true",
120          dest="list_plugins",
121          default=False,
122          help="Show the available plugins. Default: false.",
123      )
124      parser.add_argument(
125          "--no-color",
126          action="store_true",
127          dest="no_color",
128          default=False,
129          help="Do not use colors. Default: false.",
130      )
131      parser.add_argument(
132          "--no-config",
133          action="store_true",
134          dest="no_config",
135          default=False,
136          help="Do not load configuration from file. Default: false.",
137      )
138      parser.add_argument(
139          "-v",
140          "--verbose-level",
141          action="store",
142          dest="level",
143          type=valid_level,
144          default="ERROR",
145          help="Level of verbosity.",
146      )
147      parser.add_argument(
148          "-V",
149          "--version",
150          action="version",
151          version=f"archan {debug.get_version()}",
152          help="Show the current version of the program and exit.",
153      )
154      parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
155      return parser
156  
157  
158  def main(args: list[str] | None = None) -> int:
159      """Run the main program.
160  
161      This function is executed when you type `archan` or `python -m archan`.
162  
163      Parameters:
164          args: Arguments passed from the command line.
165  
166      Returns:
167          An exit code.
168      """
169      parser = get_parser()
170      opts = parser.parse_args(args=args)
171      Logger.set_level(opts.level)
172  
173      colorama_args = {"autoreset": True}
174      if opts.no_color:
175          colorama_args["strip"] = True
176      colorama.init(**colorama_args)
177  
178      config = None
179      if opts.no_config:
180          logger.info("--no-config flag used, use default configuration")
181          if opts.input_file:
182              logger.info(f"Input file specified: {opts.input_file}")
183              file_path = opts.input_file
184          else:
185              logger.info("No input file specified, will read standard input")
186              file_path = None
187          config = Config.default_config(file_path)
188      else:
189          if opts.config_file:
190              logger.info(f"Configuration file specified: {opts.config_file}")
191              config_file = opts.config_file
192          else:
193              logger.info("No configuration file specified, searching")
194              config_file = Config.find()
195          if config_file:
196              logger.info(f"Load configuration from {config_file}")
197              config = Config.from_file(config_file)
198          if config is None:
199              logger.info("No configuration file found, use default one")
200              config = Config.default_config()
201  
202      logger.debug(f"Configuration = {config}")
203      logger.debug(f"Plugins loaded = {config.plugins}")
204  
205      if opts.list_plugins:
206          logger.info("Print list of plugins")
207          config.print_plugins()
208          return 0
209  
210      logger.info("Run analysis")
211      analysis = Analysis(config)
212      try:
213          analysis.run(verbose=False)
214      except KeyboardInterrupt:
215          logger.info("Keyboard interruption, aborting")
216          return 130
217      logger.info(f"Analysis successful: {analysis.successful}")
218      logger.info("Output results as TAP")
219      analysis.output_tap()
220      return 0 if analysis.successful else 1