Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ReST rendering improved #3519

Merged
merged 1 commit into from
Mar 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions tmt/config/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import functools
import os
from contextlib import suppress
from typing import Optional, cast
from typing import Optional, TypeVar, cast

import fmf
import fmf.utils

import tmt.utils
from tmt._compat.pathlib import Path
from tmt._compat.pydantic import ValidationError
from tmt.config.models.link import LinkConfig
from tmt.config.models.themes import Theme, ThemeConfig
from tmt.container import MetadataContainer

MetadataContainerT = TypeVar('MetadataContainerT', bound='MetadataContainer')

# Config directory
DEFAULT_CONFIG_DIR = Path('~/.config/tmt')
Expand Down Expand Up @@ -79,24 +84,52 @@ def last_run(self, workdir: Path) -> None:
raise tmt.utils.GeneralError(f"Unable to save last run '{self.path}'.\n{error}")

@functools.cached_property
def fmf_tree(self) -> fmf.Tree:
def fmf_tree(self) -> Optional[fmf.Tree]:
"""
Return the configuration tree
"""

try:
return fmf.Tree(self.path)
except fmf.utils.RootError as error:
raise tmt.utils.MetadataError(f"Config tree not found in '{self.path}'.") from error
except fmf.utils.RootError:
self.logger.debug(f"Config tree not found in '{self.path}'.")

@property
return None

def _parse_config_subtree(
self, path: str, model: type[MetadataContainerT]
) -> Optional[MetadataContainerT]:
if self.fmf_tree is None:
return None

subtree = cast(Optional[fmf.Tree], self.fmf_tree.find(path))

if not subtree:
self.logger.debug(f"Config path '{path}' not found in '{self.path}'.")

return None

try:
return model.parse_obj(subtree.data)

except ValidationError as error:
raise tmt.utils.SpecificationError(
f"Invalid configuration in '{subtree.name}'."
) from error

@functools.cached_property
def link(self) -> Optional[LinkConfig]:
"""
Return the link configuration, if present.
"""

link_config = cast(Optional[fmf.Tree], self.fmf_tree.find('/link'))
if not link_config:
return None
return self._parse_config_subtree('/link', LinkConfig)

@functools.cached_property
def theme(self) -> Theme:
theme_config = self._parse_config_subtree('/theme', ThemeConfig)

if theme_config is None:
return ThemeConfig.get_default_theme()

return LinkConfig.from_fmf(link_config)
return theme_config.get_active_theme()
103 changes: 103 additions & 0 deletions tmt/config/models/themes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
from typing import Any, Optional, Union

import click

import tmt.utils
from tmt._compat.pydantic import ValidationError
from tmt._compat.typing import TypeAlias
from tmt.container import MetadataContainer

Color: TypeAlias = Union[int, tuple[int, int, int], str, None]


class Style(MetadataContainer):
"""
A collection of parameters accepted by :py:func:`click.style`.
"""

fg: Optional[Color] = None
bg: Optional[Color] = None
bold: Optional[bool] = None
dim: Optional[bool] = None
underline: Optional[bool] = None
italic: Optional[bool] = None
blink: Optional[bool] = None
strikethrough: Optional[bool] = None

def apply(self, text: str) -> str:
"""
Apply this style to a given string.
"""

return click.style(text, **self.dict())


_DEFAULT_STYLE = Style()


class Theme(MetadataContainer):
"""
A collection of items tmt uses to colorize various tokens of its CLI.
"""

restructuredtext_text: Style = _DEFAULT_STYLE

restructuredtext_literal: Style = _DEFAULT_STYLE
restructuredtext_emphasis: Style = _DEFAULT_STYLE
restructuredtext_strong: Style = _DEFAULT_STYLE

restructuredtext_literalblock: Style = _DEFAULT_STYLE
restructuredtext_literalblock_yaml: Style = _DEFAULT_STYLE
restructuredtext_literalblock_shell: Style = _DEFAULT_STYLE

restructuredtext_admonition_note: Style = _DEFAULT_STYLE
restructuredtext_admonition_warning: Style = _DEFAULT_STYLE

def to_spec(self) -> dict[str, Any]:
return {key.replace('_', '-'): value for key, value in self.dict().items()}

def to_minimal_spec(self) -> dict[str, Any]:
spec: dict[str, Any] = {}

for theme_key, style in self.to_spec().items():
style_spec = {
style_key: value for style_key, value in style.items() if value is not None
}

if style_spec:
spec[theme_key] = style_spec

return spec

@classmethod
def from_spec(cls: type['Theme'], data: Any) -> 'Theme':
try:
return Theme.parse_obj(data)

except ValidationError as error:
raise tmt.utils.SpecificationError("Invalid theme configuration.") from error

@classmethod
def from_file(cls: type['Theme'], path: tmt.utils.Path) -> 'Theme':
return Theme.from_spec(tmt.utils.yaml_to_dict(path.read_text()))


class ThemeConfig(MetadataContainer):
active_theme: str = 'default'

@classmethod
def load_theme(cls, theme_name: str) -> Theme:
try:
return Theme.from_file(
tmt.utils.resource_files(tmt.utils.Path('config/themes') / f'{theme_name}.yaml')
)

except FileNotFoundError as exc:
raise tmt.utils.GeneralError(f"No such theme '{theme_name}'.") from exc

@classmethod
def get_default_theme(cls) -> Theme:
return cls.load_theme('default')

def get_active_theme(self) -> Theme:
return self.load_theme(self.active_theme)
23 changes: 23 additions & 0 deletions tmt/config/themes/default.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# restructuredtext-text:

restructuredtext-literal:
fg: green
restructuredtext-emphasis:
fg: blue
restructuredtext-strong:
fg: blue
bold: true

restructuredtext-literalblock:
fg: green
restructuredtext-literalblock-yaml:
fg: cyan
restructuredtext-literalblock-shell:
fg: yellow

restructuredtext-admonition-note:
fg: blue
bold: true
restructuredtext-admonition-warning:
fg: yellow
bold: true
12 changes: 12 additions & 0 deletions tmt/config/themes/plain.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# restructuredtext-text:

# restructuredtext-literal:
# restructuredtext-emphasis:
# restructuredtext-strong:

# restructuredtext-literalblock:
# restructuredtext-literalblock-yaml:
# restructuredtext-literalblock-shell:

# restructuredtext-admonition-note:
# restructuredtext-admonition-warning:
2 changes: 0 additions & 2 deletions tmt/steps/prepare/ansible.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,6 @@ class PrepareAnsible(tmt.steps.prepare.PreparePlugin[PrepareAnsibleData]):
# https://tmt.readthedocs.io/en/stable/contribute.html#docs
#
"""
Prepare guest using Ansible.

Run Ansible playbooks against the guest, by running
``ansible-playbook`` for all given playbooks.

Expand Down
6 changes: 2 additions & 4 deletions tmt/trying.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,8 @@ def get_default_plans(self, run: tmt.base.Run) -> list[Plan]:
# Check user config for custom default plans. Search for all
# plans starting with the default user plan name (there might be
# more than just one).
try:
config_tree = tmt.config.Config().fmf_tree
config_tree = tmt.config.Config().fmf_tree
if config_tree is not None:
plan_name = re.escape(USER_PLAN_NAME)
# cast: once fmf is properly annotated, cast() would not be needed.
# pyright isn't able to infer the type.
Expand All @@ -220,8 +220,6 @@ def get_default_plans(self, run: tmt.base.Run) -> list[Plan]:
self.tree.tree.update(plan_dict)
self.debug("Use the default user plan config.")
return self.tree.plans(names=[f"^{plan_name}"], run=run)
except MetadataError:
self.debug("User config tree not found.")

# Use the default plan template otherwise
plan_name = re.escape(tmt.templates.DEFAULT_PLAN_NAME)
Expand Down
2 changes: 0 additions & 2 deletions tmt/utils/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,6 @@ def from_issue_url(
link_config = tmt.config.Config().link
except tmt.utils.SpecificationError:
raise
except tmt.utils.MetadataError:
return None
if not link_config:
return None

Expand Down
Loading