Skip to content

Commit

Permalink
Start splitting tmt.cli into submodules
Browse files Browse the repository at this point in the history
* `tmt.cli` remains the module with utilities and basic code,
* `tmt.cli._root` contains all commands for now, but eventualy it would
  be just the top-level command,
* `tmt status` is moved into `tmt.cli.status` as an example of how it
  would look like.
  • Loading branch information
happz committed Sep 5, 2024
1 parent a8603c7 commit 121e99d
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 285 deletions.
10 changes: 8 additions & 2 deletions tmt/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ def run_cli() -> None:
"""
try:
import tmt.utils # noqa: I001
import tmt.cli
tmt.cli.main()

# Import CLI commands.
# TODO: some kind of `import tmt.cli.*` would be nice
import tmt.cli._root
import tmt.cli.status

tmt.cli._root.main()

except ImportError as error:
print("Error: tmt package does not seem to be installed")
raise SystemExit(1) from error
Expand Down
241 changes: 241 additions & 0 deletions tmt/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
""" Basic classes and code for tmt command line interface """

import collections
import dataclasses
import enum
import functools
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar

import click
import fmf
import fmf.utils

import tmt
import tmt.base
import tmt.log
import tmt.plugins
import tmt.utils

if TYPE_CHECKING:
from tmt._compat.typing import Concatenate, ParamSpec

P = ParamSpec('P')
R = TypeVar('R')


#: A logger to use before the proper one can be established.
#:
#: .. warning::
#:
#: This logger should be used with utmost care for logging while tmt
#: is still starting. Once properly configured logger is spawned,
#: honoring relevant options, this logger should not be used anymore.
_BOOTSTRAP_LOGGER = tmt.log.Logger.get_bootstrap_logger()

#: A logger to use for exception logging.
#:
#: .. warning::
#:
#: This logger should be used with utmost care for logging exceptions
#: only, no other traffic should be allowed. On top of that, the
#: exception logging is handled by a dedicated function,
#: :py:func:`tmt.utils.show_exception` - if you find yourself in need
#: of logging an exception somewhere in the code, and you think about
#: using this logger or calling ``show_exception()`` explicitly,
#: it is highly likely you are not on the right track.
EXCEPTION_LOGGER: tmt.log.Logger = _BOOTSTRAP_LOGGER


# Explore available plugins (need to detect all supported methods first)
tmt.plugins.explore(_BOOTSTRAP_LOGGER)


class TmtExitCode(enum.IntEnum):
# Quoting the specification:

#: At least one test passed, there was no fail, warn or error.
SUCCESS = 0

#: There was a fail or warn identified, but no error.
FAIL = 1

#: Errors occurred during test execution.
ERROR = 2

#: No test results found.
NO_RESULTS_FOUND = 3

#: Tests were executed, and all reported the ``skip`` result.
ALL_TESTS_SKIPPED = 4


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Click Context Object Container
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~


@dataclasses.dataclass
class ContextObject:
"""
Click Context Object container.
In Click terms, this is "an arbitrary object of user data." In this container,
tmt CLI code stores all structures relevant for the command execution. The
container itself is then attached to :py:class:`click.Context` object Click
manages across commands.
"""

# "Parent" Click context
cli_context: 'Context'

logger: tmt.log.Logger
common: tmt.utils.Common
fmf_context: tmt.utils.FmfContext
tree: tmt.Tree
steps: set[str] = dataclasses.field(default_factory=set)
clean: Optional[tmt.Clean] = None
clean_logger: Optional[tmt.log.Logger] = None
clean_partials: collections.defaultdict[str, list[tmt.base.CleanCallback]] = dataclasses.field(
default_factory=lambda: collections.defaultdict(list))
run: Optional[tmt.Run] = None


class Context(click.Context):
"""
Custom :py:class:`click.Context`-like class for typing purposes.
Objects of this class are never instantiated, it serves only as a type
stub in commands below, to simplify handling and static analysis of
``context.obj``. There is no added functionality, the only change is
a much narrower type of ``obj`` attribute.
This class shall be used instead of the original :py:class:`click.Context`.
Click is obviously not aware of our type annotations, and ``context``
objects managed by Click would always be of type :py:class:`click.Context`,
we would just convince mypy their ``obj`` attribute is no longer ``Any``.
"""

# In contrast to the original Context, we *know* we do set obj to a valid
# object, and every time we touch it, it should absolutely be not-None.
obj: ContextObject

max_content_width: Optional[int]


def pass_context(fn: 'Callable[Concatenate[Context, P], R]') -> 'Callable[P, R]':
"""
Custom :py:func:`click.pass_context`-like decorator.
Complementing the :py:class:`Context`, the goal of this decorator to
announce the correct type of the ``context`` parameter. The original
decorator annotates the parameter as ``click.Context``, but that is not
what our command callables accept. So, on this boundary between tmt code
and ``click`` API, we trick type checkers by isolating the necessary
``type: ignore[arg-type]``.
"""

return click.pass_context(fn) # type: ignore[arg-type]


@dataclasses.dataclass
class CliInvocation:
"""
A single CLI invocation of a tmt subcommand.
Bundles together the Click context and options derived from it.
A context alone might be good enough, but sometimes tmt needs to
modify saved options. For custom command line options injected
manually 'sources' is used to keep the parameter source.
Serves as a clear boundary between invocations of classes
representing various tmt subcommands and groups.
"""

context: Optional[Context]
options: dict[str, Any]

@classmethod
def from_context(cls, context: Context) -> 'CliInvocation':
return CliInvocation(context=context, options=context.params)

@classmethod
def from_options(cls, options: dict[str, Any]) -> 'CliInvocation':
""" Inject custom options coming from the command line """
invocation = CliInvocation(context=None, options=options)

# ignore[reportGeneralTypeIssues]: pyright has troubles understanding it
# *is* possible to assign to a cached property. Might an issue of our
# simplified implementation.
# ignore[unused-ignore]: silencing mypy's complaint about silencing
# pyright's warning :)
invocation.option_sources = { # type: ignore[reportGeneralTypeIssues,unused-ignore]
key: click.core.ParameterSource.COMMANDLINE
for key in options
}
return invocation

@functools.cached_property
def option_sources(self) -> dict[str, click.core.ParameterSource]:
if not self.context:
return {}

return self.context._parameter_source


# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
# Custom Group
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

class CustomGroup(click.Group):
""" Custom Click Group """

# ignore[override]: expected, we want to use more specific `Context`
# type than the one declared in superclass.
def list_commands(self, context: Context) -> list[str]: # type: ignore[override]
""" Prevent alphabetical sorting """
return list(self.commands.keys())

# ignore[override]: expected, we want to use more specific `Context`
# type than the one declared in superclass.
def get_command( # type: ignore[override]
self,
context: Context,
cmd_name: str
) -> Optional[click.Command]:
""" Allow command shortening """
# Backward-compatible 'test convert' (just temporary for now FIXME)
cmd_name = cmd_name.replace('convert', 'import')
# Support both story & stories
cmd_name = cmd_name.replace('story', 'stories')
found = click.Group.get_command(self, context, cmd_name)
if found is not None:
return found
matches = [command for command in self.list_commands(context)
if command.startswith(cmd_name)]
if not matches:
return None
if len(matches) == 1:
return click.Group.get_command(self, context, matches[0])
context.fail(f"Did you mean {fmf.utils.listed(sorted(matches), join='or')}?")
return None


class HelpFormatter(click.HelpFormatter):
""" Custom help formatter capable of rendering ReST syntax """

# Override parent implementation
def write_dl(
self,
rows: Sequence[tuple[str, str]],
col_max: int = 30,
col_spacing: int = 2) -> None:
rows = [
(option, tmt.utils.render_rst(help, _BOOTSTRAP_LOGGER))
for option, help in rows
]

super().write_dl(rows, col_max=col_max, col_spacing=col_spacing)


click.Context.formatter_class = HelpFormatter
Loading

0 comments on commit 121e99d

Please sign in to comment.