diff --git a/.config/constraints.txt b/.config/constraints.txt index 90bc3391fc..e7f3a804b8 100644 --- a/.config/constraints.txt +++ b/.config/constraints.txt @@ -47,10 +47,8 @@ linkchecker==10.5.0 # via mkdocs-ansible markdown==3.7 # via markdown-include, mkdocs, mkdocs-autorefs, mkdocs-htmlproofer-plugin, mkdocs-material, mkdocstrings, pymdown-extensions markdown-exec==1.9.3 # via mkdocs-ansible markdown-include==0.8.1 # via mkdocs-ansible -markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2, mkdocs, mkdocs-autorefs, mkdocstrings mccabe==0.7.0 # via pylint -mdurl==0.1.2 # via markdown-it-py mergedeep==1.3.4 # via mkdocs, mkdocs-get-deps mkdocs==1.6.1 # via mkdocs-ansible, mkdocs-autorefs, mkdocs-gen-files, mkdocs-htmlproofer-plugin, mkdocs-macros-plugin, mkdocs-material, mkdocs-minify-plugin, mkdocs-monorepo-plugin, mkdocstrings mkdocs-ansible==24.12.0 # via ansible-lint (pyproject.toml) @@ -76,7 +74,7 @@ platformdirs==4.3.6 # via black, mkdocs-get-deps, mkdocstrings, pylint, to pluggy==1.5.0 # via pytest, tox psutil==6.1.0 # via pytest-xdist, ansible-lint (pyproject.toml) pycparser==2.22 # via cffi -pygments==2.18.0 # via mkdocs-material, rich +pygments==2.18.0 # via mkdocs-material pylint==3.3.2 # via ansible-lint (pyproject.toml) pymdown-extensions==10.12 # via markdown-exec, mkdocs-ansible, mkdocs-material, mkdocstrings pyproject-api==1.8.0 # via tox @@ -92,7 +90,6 @@ pyyaml-env-tag==0.1 # via mkdocs referencing==0.35.1 # via jsonschema, jsonschema-specifications, types-jsonschema regex==2024.11.6 # via mkdocs-material requests==2.32.3 # via linkchecker, mkdocs-htmlproofer-plugin, mkdocs-material -rich==13.9.4 # via ansible-lint (pyproject.toml) rpds-py==0.22.1 # via jsonschema, referencing ruamel-yaml==0.18.6 # via ansible-lint (pyproject.toml) ruamel-yaml-clib==0.2.12 # via ruamel-yaml, ansible-lint (pyproject.toml) diff --git a/.config/requirements-lock.txt b/.config/requirements-lock.txt index 229d09e33e..3628692135 100644 --- a/.config/requirements-lock.txt +++ b/.config/requirements-lock.txt @@ -12,18 +12,14 @@ importlib-metadata==8.5.0 # via ansible-lint (pyproject.toml) jinja2==3.1.4 # via ansible-core jsonschema==4.23.0 # via ansible-compat, ansible-lint (pyproject.toml) jsonschema-specifications==2024.10.1 # via jsonschema -markdown-it-py==3.0.0 # via rich markupsafe==3.0.2 # via jinja2 -mdurl==0.1.2 # via markdown-it-py mypy-extensions==1.0.0 # via black packaging==24.2 # via ansible-compat, ansible-core, black, ansible-lint (pyproject.toml) pathspec==0.12.1 # via black, yamllint, ansible-lint (pyproject.toml) platformdirs==4.3.6 # via black pycparser==2.22 # via cffi -pygments==2.18.0 # via rich pyyaml==6.0.2 # via ansible-compat, ansible-core, yamllint, ansible-lint (pyproject.toml) referencing==0.35.1 # via jsonschema, jsonschema-specifications -rich==13.9.4 # via ansible-lint (pyproject.toml) rpds-py==0.22.1 # via jsonschema, referencing ruamel-yaml==0.18.6 # via ansible-lint (pyproject.toml) ruamel-yaml-clib==0.2.12 # via ruamel-yaml diff --git a/.config/requirements.in b/.config/requirements.in index d52ad7ef30..07bffe611e 100644 --- a/.config/requirements.in +++ b/.config/requirements.in @@ -10,7 +10,6 @@ jsonschema>=4.10.0 # MIT, version needed for improved errors packaging>=21.3 # Apache-2.0,BSD-2-Clause pathspec>=0.10.3 # Mozilla Public License 2.0 (MPL 2.0) pyyaml>=5.4.1 # MIT (centos 9 has 5.3.1) -rich>=12.0.0 # MIT ruamel.yaml>=0.18.5 # MIT subprocess-tee>=0.4.1 # MIT, used by ansible-compat yamllint >= 1.30.0 # GPLv3 diff --git a/.config/vulture_whitelist.py b/.config/vulture_whitelist.py index b252a3830f..89cbd3e472 100644 --- a/.config/vulture_whitelist.py +++ b/.config/vulture_whitelist.py @@ -1,3 +1,4 @@ +# type: ignore _.configured # unused attribute (src/ansiblelint/__main__.py:140) configured # unused variable (src/ansiblelint/config.py:132) _.keep_trailing_newline # unused attribute (src/ansiblelint/rules/jinja.py:280) @@ -13,3 +14,15 @@ _.compact_seq_map # unused attribute (src/ansiblelint/yaml_utils.py:925) _.Constructor # unused attribute (src/ansiblelint/yaml_utils.py:946) _.preserve_quotes # unused attribute (src/ansiblelint/yaml_utils.py:956) +_.BLACK # unused variable (src/ansiblelint/output.py:172) +_.YELLOW # unused variable (src/ansiblelint/output.py:175) +_.CYAN # unused variable (src/ansiblelint/output.py:178) +_.WHITE # unused variable (src/ansiblelint/output.py:179) +_.GREY # unused variable (src/ansiblelint/output.py:180) +_.BRIGHT_RED # unused variable (src/ansiblelint/output.py:181) +_.BRIGHT_GREEN # unused variable (src/ansiblelint/output.py:182) +_.BRIGHT_YELLOW # unused variable (src/ansiblelint/output.py:183) +_.BRIGHT_BLUE # unused variable (src/ansiblelint/output.py:184) +_.BRIGHT_MAGENTA # unused variable (src/ansiblelint/output.py:185) +_.BRIGHT_CYAN # unused variable (src/ansiblelint/output.py:186) +_.BRIGHT_WHITE # unused variable (src/ansiblelint/output.py:187) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10b323c428..64ce528150 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -164,7 +164,6 @@ repos: - pytest-mock - pytest>=7.2.2 - pip>=22.3.1 - - rich>=13.2.0 - ruamel-yaml-clib>=0.2.8 - ruamel-yaml>=0.18.6 - subprocess-tee @@ -195,7 +194,6 @@ repos: - pip>=22.3.1 - pytest-mock - pytest>=7.2.2 - - rich>=13.2.0 - ruamel-yaml-clib>=0.2.8 - ruamel-yaml>=0.18.6 - subprocess-tee @@ -222,7 +220,6 @@ repos: - pytest-mock - pytest>=7.2.2 - pyyaml - - rich>=13.2.0 - ruamel-yaml-clib>=0.2.7 - ruamel-yaml>=0.18.2 - setuptools # needed for pkg_resources import diff --git a/cspell.config.yaml b/cspell.config.yaml index c80fd0b04c..01d8988530 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -9,6 +9,7 @@ dictionaries: - bash - words - python +enabled: true ignorePaths: - cspell.config.yaml # The requirements file diff --git a/src/ansiblelint/__main__.py b/src/ansiblelint/__main__.py index 5ef06e14bf..17585323e6 100755 --- a/src/ansiblelint/__main__.py +++ b/src/ansiblelint/__main__.py @@ -34,8 +34,6 @@ from ansible_compat.prerun import get_cache_dir from filelock import BaseFileLock, FileLock, Timeout -from rich.markdown import Markdown -from rich.markup import escape from ansiblelint.constants import RC, SKIP_SCHEMA_UPDATE @@ -61,7 +59,6 @@ from ansiblelint.loaders import load_ignore_txt from ansiblelint.output import ( console, - console_options, console_stderr, reconfigure, render_yaml, @@ -86,7 +83,7 @@ class LintLogHandler(logging.Handler): def emit(self, record: logging.LogRecord) -> None: try: msg = self.format(record) - console_stderr.print(f"[dim]{msg}[/dim]", highlight=False) + console_stderr.print(f"[dim]{msg}[/]") except RecursionError: # See issue 36272 raise except Exception: # pylint: disable=broad-exception-caught # noqa: BLE001 @@ -170,7 +167,6 @@ def _do_list(rules: RulesCollection) -> int: if options.list_rules: console.print( rules_as_str(rules), - highlight=False, ) return 0 @@ -282,16 +278,15 @@ def main(argv: list[str] | None = None) -> int: argv = sys.argv cache_dir_lock = initialize_options(argv[1:]) - console_options["force_terminal"] = options.colored - reconfigure(console_options) + reconfigure(colored=options.colored) if options.version: deps = get_deps_versions() msg = f"ansible-lint [repr.number]{__version__}[/] using[dim]" for k, v in deps.items(): - msg += f" {escape(k)}:[repr.number]{v}[/]" + msg += f" {k}:[repr.number]{v}[/]" msg += "[/]" - console.print(msg, markup=True, highlight=False) + console.print(msg, markup=True) msg = get_version_warning() if msg: console.print(msg) @@ -334,7 +329,7 @@ def main(argv: list[str] | None = None) -> int: if options.list_profiles: from ansiblelint.generate_docs import profiles_as_md - console.print(Markdown(profiles_as_md())) + profiles_as_md().display() return 0 app = get_app( diff --git a/src/ansiblelint/app.py b/src/ansiblelint/app.py index baf3b0bffb..77554e61dd 100644 --- a/src/ansiblelint/app.py +++ b/src/ansiblelint/app.py @@ -12,8 +12,6 @@ from typing import TYPE_CHECKING, Any from ansible_compat.runtime import Runtime -from rich.markup import escape -from rich.table import Table from ansiblelint import formatters from ansiblelint._mockings import _perform_mockings @@ -83,7 +81,6 @@ def render_matches(self, matches: list[MatchError]) -> None: console.print( self.formatter.format_result(matches), markup=False, - highlight=False, ) return @@ -97,8 +94,7 @@ def render_matches(self, matches: list[MatchError]) -> None: ) for match in ignored_matches: if match.ignored: - # highlight must be off or apostrophes may produce unexpected results - console.print(self.formatter.apply(match), highlight=False) + console.print(self.formatter.apply(match)) if fatal_matches: _logger.warning( "Listing %s violation(s) that are fatal", @@ -106,7 +102,7 @@ def render_matches(self, matches: list[MatchError]) -> None: ) for match in fatal_matches: if not match.ignored: - console.print(self.formatter.apply(match), highlight=False) + console.print(self.formatter.apply(match)) # If run under GitHub Actions we also want to emit output recognized by it. if os.getenv("GITHUB_ACTIONS") == "true" and os.getenv("GITHUB_WORKFLOW"): @@ -118,7 +114,6 @@ def render_matches(self, matches: list[MatchError]) -> None: console_stderr.print( formatter.apply(match), markup=False, - highlight=False, ) # If sarif_file is set, we also dump the results to a sarif file. @@ -316,33 +311,16 @@ def report_summary( # pylint: disable=too-many-locals # noqa: C901 stars = "" if summary.tag_stats: - table = Table( - title="Rule Violation Summary", - collapse_padding=True, - box=None, - show_lines=False, - ) - table.add_column("count", justify="right") - table.add_column("tag") - table.add_column("profile") - table.add_column("rule associated tags") + table = "# Rule Violation Summary\n\n" for tag, stats in summary.tag_stats.items(): - table.add_row( - str(stats.count), - f"[link={RULE_DOC_URL}{tag.split('[')[0]}]{escape(tag)}[/link]", - stats.profile, - f"{', '.join(stats.associated_tags)}{' (warning)' if stats.warning else ''}", - style="yellow" if stats.warning else "red", - ) - # rate stars for the top 5 profiles (min would not get + table += f"{stats.count:3} [link={RULE_DOC_URL}]{tag.split('[')[0]}[/link] [dim]profile:{profile} tags:{','.join(stats.associated_tags)}[/]\n" rating = 5 - (len(PROFILES.keys()) - passed_profile_count) if 0 < rating < 6: stars = f" Rating: {rating}/5 star" - console_stderr.print(table) - console_stderr.print() + console.print(table, file=sys.stderr) - msg = "[green]Passed[/]" if is_success else "[red][bold]Failed[/][/]" + msg = "[success]Passed[/]" if is_success else "[failed][bold]Failed[/][/]" msg += f": {summary.failures} failure(s), {summary.warnings} warning(s)" if summary.fixed: diff --git a/src/ansiblelint/cli.py b/src/ansiblelint/cli.py index 379c446a04..fbb6c0fb1a 100644 --- a/src/ansiblelint/cli.py +++ b/src/ansiblelint/cli.py @@ -610,7 +610,7 @@ def get_config(arguments: list[str]) -> Options: log_entries.append( ( logging.INFO, - f"Identified [filename]{project_dir}[/] as project root due [bold]{method}[/].", + f"Identified [repr.path]{project_dir}[/] as project root due [bold]{method}[/].", ), ) diff --git a/src/ansiblelint/config.py b/src/ansiblelint/config.py index dacc5f6c5c..b99e3c5629 100644 --- a/src/ansiblelint/config.py +++ b/src/ansiblelint/config.py @@ -329,7 +329,7 @@ def get_version_warning() -> str: if current_version > new_version: msg = "[dim]You are using a pre-release version of ansible-lint.[/]" elif current_version < new_version: - msg = f"""[warning]A new release of ansible-lint is available: [red]{current_version}[/] → [green][link={html_url}]{new_version}[/][/][/]""" + msg = f"""[warning]A new release of ansible-lint is available: [warning]{current_version}[/] → [success][link={html_url}]{new_version}[/link][/][/]""" msg += f" Upgrade by running: [info]{pip}[/]" return msg diff --git a/src/ansiblelint/formatters/__init__.py b/src/ansiblelint/formatters/__init__.py index bde8af122b..e8a3bd9cc5 100644 --- a/src/ansiblelint/formatters/__init__.py +++ b/src/ansiblelint/formatters/__init__.py @@ -8,8 +8,6 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Generic, TypeVar -from rich.markup import escape - from ansiblelint.config import options from ansiblelint.version import __version__ @@ -58,7 +56,7 @@ def apply(self, match: MatchError) -> str: @staticmethod def escape(text: str) -> str: """Escapes a string to avoid processing it as markup.""" - return escape(text) + return text # escape(text) class Formatter(BaseFormatter): # type: ignore[type-arg] @@ -66,14 +64,14 @@ class Formatter(BaseFormatter): # type: ignore[type-arg] def apply(self, match: MatchError) -> str: _id = getattr(match.rule, "id", "000") - result = f"[{match.level}][bold][link={match.rule.url}]{self.escape(match.tag)}[/link][/][/][dim]:[/] [{match.level}]{self.escape(match.message)}[/]" + result = f"[{match.level}][link={match.rule.url}]{match.tag}[/link][dim]:[/] [{match.level}]{self.escape(match.message)}[/]" if match.level != "error": - result += f" [dim][{match.level}]({match.level})[/][/]" + result += f" [dim][{match.level}]({match.level})[/]" if match.ignored: result += " [dim]# ignored[/]" result += ( "\n" - f"[filename]{self._format_path(match.filename or '')}[/]:{match.position}" + f"[repr.path]{self._format_path(match.filename or '')}[/]:{match.position}" ) if match.details: result += f" [dim]{self.escape(str(match.details))}[/]" @@ -87,7 +85,7 @@ class QuietFormatter(BaseFormatter[Any]): def apply(self, match: MatchError) -> str: return ( f"[{match.level}]{match.rule.id}[/] " - f"[filename]{self._format_path(match.filename or '')}[/]:{match.position}" + f"[repr.path]{self._format_path(match.filename or '')}[/]:{match.position}" ) @@ -96,8 +94,8 @@ class ParseableFormatter(BaseFormatter[Any]): def apply(self, match: MatchError) -> str: result = ( - f"[filename]{self._format_path(match.filename or '')}[/][dim]:{match.position}:[/] " - f"[{match.level}][bold]{self.escape(match.tag)}[/bold]" + f"[repr.path]{self._format_path(match.filename or '')}[/][dim]:{match.position}:[/] " + f"[{match.level}][bold]{self.escape(match.tag)}[/]" f"{ f': {match.message}' if not options.quiet else '' }[/]" ) if match.level != "error": diff --git a/src/ansiblelint/generate_docs.py b/src/ansiblelint/generate_docs.py index 5973f50dc0..98865f1bf4 100644 --- a/src/ansiblelint/generate_docs.py +++ b/src/ansiblelint/generate_docs.py @@ -2,6 +2,7 @@ from ansiblelint.config import PROFILES from ansiblelint.constants import RULE_DOC_URL +from ansiblelint.output import Markdown from ansiblelint.rules import RulesCollection, TransformMixin @@ -12,7 +13,7 @@ def rules_as_str(rules: RulesCollection) -> str: if issubclass(rule.__class__, TransformMixin): rule.tags.insert(0, "autofix") tag = f"{','.join(rule.tags)}" if rule.tags else "" - result += f"- [repr.url][link={RULE_DOC_URL}{rule.id}/]{rule.id}[/link][/] {rule.shortdesc}\n[dim] tags:{tag}[/dim]" + result += f"- [link={RULE_DOC_URL}{rule.id}/]{rule.id}[/link] {rule.shortdesc}\n[dim] tags:{tag}[/]" if rule.version_changed and rule.version_changed != "historic": result += f"[dim] modified:{rule.version_changed}[/]" @@ -21,7 +22,7 @@ def rules_as_str(rules: RulesCollection) -> str: return result -def profiles_as_md(*, header: bool = False, docs_url: str = RULE_DOC_URL) -> str: +def profiles_as_md(*, header: bool = False, docs_url: str = RULE_DOC_URL) -> Markdown: """Return markdown representation of supported profiles.""" result = "" @@ -57,4 +58,4 @@ def profiles_as_md(*, header: bool = False, docs_url: str = RULE_DOC_URL) -> str result += f"- [{rule}]({rule_data['url']})\n" result += "\n" - return result + return Markdown(result) diff --git a/src/ansiblelint/output.py b/src/ansiblelint/output.py index fc92a6300e..5490814130 100644 --- a/src/ansiblelint/output.py +++ b/src/ansiblelint/output.py @@ -2,16 +2,6 @@ from __future__ import annotations -from typing import Any - -import rich -import rich.markdown -from rich.console import Console -from rich.default_styles import DEFAULT_STYLES -from rich.style import Style -from rich.syntax import Syntax -from rich.theme import Theme - # WARNING: When making style changes, be sure you test the output of # `ansible-lint -L` on multiple terminals with dark/light themes, including: # - iTerm2 (macOS) - bold might not be rendered differently @@ -43,63 +33,427 @@ # super|dict|print: #0086b3 light-blue # __name__: #bb60d5 (magenta) # string: #dd1144 (light-red) -DEFAULT_STYLES.update( - { - "markdown.code": Style(color="bright_black"), - "markdown.code_block": Style(dim=True, color="cyan"), - }, -) +# See: https://github.com/ansible/ansible-dev-environment/blob/main/src/ansible_dev_environment/output.py +# cspell: ignore mdcat, mdless +import dataclasses +import os +import re +import shutil +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass +from io import StringIO +from typing import Any, TextIO + +from ansiblelint.logger import _logger + +md_cmd: str | None = None +md_renderers = { + "mdcat": ["mdcat"], # (rust) + "rich-cli": ["rich-cli", "--markdown"], # (python) + "glow": ["glow"], # nice output but hyperlinks are exploded (go) + "mdless": [ + "mdless", + "--autolink", + "--no-pager", + ], # ugly heading, no hyperlinks (ruby) +} -_theme = Theme( - { - "info": "cyan", - "warning": "yellow", - "danger": "bold red", - "title": "yellow", - "error": "bright_red", - "filename": "blue", - }, +_styles = ( + "dim", + "b", + # logging + "notset", + "debug", + "info", + "warning", + "error", + "critical", + # results + "failed", + "success", + # reset + "normal", + # data types + "number", + "path", ) -console_options: dict[str, Any] = {"emoji": False, "theme": _theme, "soft_wrap": True} -console_options_stderr = console_options.copy() -console_options_stderr["stderr"] = True -console = rich.get_console() -console_stderr = Console(**console_options_stderr) + +# Based on Ansible implementation +def to_bool(value: Any) -> bool: # pragma: no cover + """Return a bool for the arg.""" + if value is None or isinstance(value, bool): + return bool(value) + if isinstance(value, str): + value = value.lower() + return value in ("yes", "on", "1", "true", 1) + + +# @cachetools.cached() +def should_do_markup(stream: TextIO = sys.stdout) -> bool: # pragma: no cover + """Decide about use of ANSI colors.""" + py_colors = None + + # https://xkcd.com/927/ + for env_var in ["PY_COLORS", "CLICOLOR", "FORCE_COLOR", "ANSIBLE_FORCE_COLOR"]: + value = os.environ.get(env_var, None) + if value is not None: + py_colors = to_bool(value) + break + + # If deliberately disabled colors + if os.environ.get("NO_COLOR", None): + return False + + # User configuration requested colors + if py_colors is not None: + return to_bool(py_colors) + + term = os.environ.get("TERM", "") + if "xterm" in term: + return True + + if term == "dumb": + return False + + # Use tty detection logic as last resort because there are numerous + # factors that can make isatty return a misleading value, including: + # - stdin.isatty() is the only one returning true, even on a real terminal + # - stderr returning false if user user uses a error stream coloring solution + return stream.isatty() + + +@dataclasses.dataclass +class PlainStyle: + """Theme.""" + + failed = "" + success = "" + normal = "" + dim = "" + bold = "" + # logging + notset = "" + debug = "" + info = "" + warning = "" + error = "" + critical = "" + + # data types + number = "" + path = "" + link = "" + + @classmethod + def render_link(cls, uri: str, label: str | None = None) -> str: + """Return a link.""" + return label or uri + + +@dataclasses.dataclass +class AnsiStyle(PlainStyle): + """Theme.""" + + @dataclass + class ANSI: + """Color constants.""" + + BLACK = "\033[30m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + WHITE = "\033[37m" + GREY = "\033[90m" # Bright black? + BRIGHT_RED = "\033[91m" + BRIGHT_GREEN = "\033[92m" + BRIGHT_YELLOW = "\033[93m" + BRIGHT_BLUE = "\033[94m" + BRIGHT_MAGENTA = "\033[95m" + BRIGHT_CYAN = "\033[96m" + BRIGHT_WHITE = "\033[97m" + END = "\033[0m" + # more complex + BOLD = "\033[1m" + DIM = "\033[1;30m" + BOLD_CYAN = "\033[1;36m" + + warning = "\033[33m" # yellow + error = ANSI.RED # "\033[31m" # red + info = ANSI.BLUE + debug = ANSI.BLUE + notset = ANSI.BLUE + + failed = ANSI.RED + success = ANSI.GREEN + + normal = ANSI.END + dim = ANSI.DIM + bold = ANSI.BOLD + # data types + number = ANSI.BOLD_CYAN + path = ANSI.MAGENTA # do not use same color as link + link = ANSI.BLUE + + @classmethod + def render_link(cls, uri: str, label: str | None = None) -> str: + """Return a link.""" + if label is None: + label = uri + parameters = "" + + # OSC 8 ; params ; URI ST OSC 8 ;; ST + escape_mask = "\033]8;{};{}\033\\{}\033]8;;\033\\" + + return cls.link + escape_mask.format(parameters, uri, label) + cls.normal + + +class Markdown(str): # noqa: FURB189 + """Markdown string.""" + + __slots__ = ["text"] + + def __init__(self, text: str) -> None: + """Constructor.""" + super().__init__() + self.text = text + + def display(self) -> None: + """Display markdown text in the terminal using an external renderer if available.""" + global md_cmd # pylint: disable=global-statement + if md_cmd is None: + for v in md_renderers: + if shutil.which(v): + md_cmd = v + break + if not md_cmd: + msg = f"No know markdown renderer found ({', '.join(md_renderers)}), output as plain text." + _logger.warning(msg) + md_cmd = "" + else: + msg = f"Using markdown renderer: {md_cmd}" + _logger.info(msg) + + if md_cmd: + subprocess.run( # noqa: S603 + md_renderers[md_cmd], + input=self.text, + text=True, + check=False, + ) + else: + console.print(self.text) + + +color: bool = True -def reconfigure(new_options: dict[str, Any]) -> None: +# https://peps.python.org/pep-3101/ +__all__ = ("Console", "color") + + +# pylint: disable=too-few-public-methods +class Console: + """Console.""" + + colored: bool = True + style: type[PlainStyle] = AnsiStyle + + def __init__(self, file: TextIO | None = sys.stdout): + """Console constructor.""" + self._file = file + + # pylint: disable=W0613,too-many-arguments) + def print( + self, + *values: Any, + sep: str | None = " ", + end: str | None = "\n", + file: TextIO | None = None, + flush: bool = False, + markup: bool = True, # not in stdlib print + ) -> None: + """Internal print implementation.""" + buffer = StringIO() + print(*values, sep=sep, end="", file=buffer, flush=True) + buffer.seek(0) + data = buffer.read() + print(self.render(data), end=end, file=file or self._file) + + def render(self, text: str) -> str: + """Parses a string containing nested BBCode with a generic block terminator ([/]).""" + style: type[PlainStyle] = AnsiStyle if self.colored else PlainStyle + # Define BBCode-to-HTML mappings + bbcode_to_html = { + "bold": (style.bold, style.normal), + "dim": (style.dim, style.normal), + # logging + "warning": (style.warning, style.normal), + "error": (style.error, style.normal), + "info": (style.info, style.normal), + "debug": (style.debug, style.normal), + "noteset": (style.notset, style.normal), + # data types + "repr.path": (style.path, style.normal), + "repr.number": (style.number, style.normal), + "repr.link": (style.link, style.normal), + "failed": (style.failed, style.normal), + "success": (style.success, style.normal), + # "u": ("", ""), + # "quote": ("
", "
"), + # "url": ('', ""), + } + # Regex to find opening tags and their content + # rf"\[({"|".join(_styles)})(?:=(.*?))?\](.*?)\[/\]", re.DOTALL + # tag_pattern = re.compile(r"\[(\w+)(?:=(.*?))?\](.*?)\[/\]", re.DOTALL) + # tag_pattern = re.compile(r"\[(\w+)(?:=(.*?))?\]|\[/\]") + tag_pattern = re.compile(r"\[([\w\.]+)(?:=(.*?))?\]|\[/\]") + + def replace_bb_links(text: str) -> str: + """Replaces BBCode-style links ([link=url]title[/link]) with HTML tags. + + Args: + text (str): The input text containing BBCode links. + + Returns: + str: The text with BBCode links replaced by HTML tags. + """ + # Define the regex pattern + pattern = r"\[link=(.+?)\](.+?)\[/link\]" + + # Replace matches with HTML tags + + def replacement(match: re.Match[str]) -> str: + url = match.group(1) # The URL part from [link=url] + title = match.group(2) # + return style.render_link(url, title) + + result = re.sub(pattern, replacement, text) + return result + + def replace_bb_tags(text: str) -> str: + """Processes the text with a stack-based approach to handle nested tags.""" + # Incomplete implementation as it does not track full ANSI behavior + # and only remembers to reset the style when tags ends. + stack = [] # Stack to keep track of open tags + result = [] # Result list to build the output HTML + pos = 0 # Current position in the text + + for match in tag_pattern.finditer(text): + start, end = match.span() + tag = match.group(1) + param = match.group(2) + + # Add plain text before this tag + result.append(text[pos:start]) + pos = end + + if tag: # Opening tag + if tag in bbcode_to_html: + # Push tag and param onto the stack + stack.append((tag, param)) + opening, _ = bbcode_to_html[tag] + if param: + opening = opening.replace("{param}", param) + result.append(opening) + else: + # Preserve unknown tags as-is + result.append(match.group(0)) + # if tag.startswith("link="): + stack.append(("unknown", None)) # Track unknown tags + # continue + else: # Closing tag ([/]) + if stack: + open_tag, open_param = stack.pop() + if open_tag in bbcode_to_html: + _, closing = bbcode_to_html[open_tag] + result.append(closing) + else: + # Preserve unmatched closing tag for unknown tags + result.append("[/]") + else: + # Preserve unmatched closing tags + result.append("[/]") + + # Add remaining plain text after the last tag + result.append(text[pos:]) + + # Close any unclosed tags + while stack: + open_tag, open_param = stack.pop() # noqa: F841 + if open_tag != "unknown": + _, closing = bbcode_to_html[open_tag] + result.append(closing) + + return "".join(result) + + return replace_bb_links(replace_bb_tags(text)) + + +console = Console() +console_stderr = Console(file=sys.stderr) + + +def reconfigure(colored: None | bool = None) -> None: """Reconfigure console options.""" - console_options = new_options # pylint: disable=redefined-outer-name - rich.reconfigure(**new_options) - # see https://github.com/willmcgugan/rich/discussions/484#discussioncomment-200182 - new_console_options_stderr = console_options.copy() - new_console_options_stderr["stderr"] = True - tmp_console = Console(**new_console_options_stderr) - console_stderr.__dict__ = tmp_console.__dict__ + if colored is not None: + console.colored = colored + console_stderr.colored = colored -def render_yaml(text: str) -> Syntax: +def render_yaml(text: str) -> str: """Colorize YAMl for nice display.""" - return Syntax(text, "yaml", theme="ansi_dark") - - -# pylint: disable=redefined-outer-name,unused-argument -def _rich_codeblock_custom_rich_console( - self: rich.markdown.CodeBlock, - console: Console, # noqa: ARG001 - options: rich.console.ConsoleOptions, # noqa: ARG001 -) -> rich.console.RenderResult: # pragma: no cover - code = str(self.text).rstrip() - syntax = Syntax( - code, - self.lexer_name, - theme=self.theme, - word_wrap=True, - background_color="default", - ) - yield syntax - - -rich.markdown.CodeBlock.__rich_console__ = _rich_codeblock_custom_rich_console # type: ignore[method-assign] + return text + + +_ReStringMatch = re.Match[str] # regex match object +_ReSubCallable = Callable[[_ReStringMatch], str] # Callable invoked by re.sub +_EscapeSubMethod = Callable[ + [_ReSubCallable, str, int], str +] # Sub method of a compiled re +# _EscapeSubMethod = Callable[[Callable[[re.Match[str]], str], str], str] # type +# _escape: _EscapeSubMethod = re.compile(r"(\\*)(\[[a-z#/@][^[]*?])").sub, + + +# def _escape_backslashes(match: re.Match[str]) -> str: +# """Internally used by escape to replace backslashes.""" +# _, text = match.groups() +# return f"{_}{_}\\{text}" + + +def escape( + markup: str, + _escape: _EscapeSubMethod = re.compile(r"(\\*)(\[[a-z#/@][^[]*?])").sub, +) -> str: + """Escapes text so that it won't be interpreted as markup. + + Args: + markup (str): Content to be inserted in to markup. + + Returns: + str: Markup with square brackets escaped. + """ + + def escape_backslashes(match: re.Match[str]) -> str: + """Called by re.sub replace matches.""" + backslashes, text = match.groups() + return f"{backslashes}{backslashes}\\{text}" + + markup = _escape(escape_backslashes, markup, 0) + if markup.endswith("\\") and not markup.endswith("\\\\"): + return markup + "\\" + + return markup + + +if __name__ == "__main__": + console.print("foo [bold]bold[/] [repr.number]123[/] [repr.path]/dev/null[/]") + console.print("foo [dim]dimmed[/]") + console.print("foo [error]dimmed[/] [link=https://google.com]google.com[/link]") + console.print("foo [error]dimmed[/] [link=https://google.com]name[casing][/link]") diff --git a/test/test_cli_role_paths.py b/test/test_cli_role_paths.py index 131c3b550d..db67040a6c 100644 --- a/test/test_cli_role_paths.py +++ b/test/test_cli_role_paths.py @@ -107,7 +107,7 @@ def test_run_playbook(local_test_dir: Path) -> None: "role-name: Role name invalid-name does not match", id="normal", ), - pytest.param(["--skip-list", "role-name"], "", id="skipped"), + pytest.param(["--nocolor", "--skip-list", "role-name"], "", id="skipped"), ), ) def test_run_role_name_invalid( @@ -119,10 +119,11 @@ def test_run_role_name_invalid( cwd = local_test_dir role_path = "roles/invalid-name" - result = run_ansible_lint(*args, role_path, cwd=cwd) + env = {"NO_COLOR": "1"} + result = run_ansible_lint(*args, role_path, cwd=cwd, env=env) assert result.returncode == (2 if expected_msg else 0), result if expected_msg: - assert expected_msg in strip_ansi_escape(result.stdout) + assert expected_msg in result.stdout def test_run_role_name_with_prefix(local_test_dir: Path) -> None: @@ -150,7 +151,7 @@ def test_run_invalid_role_name_from_meta(local_test_dir: Path) -> None: cwd = local_test_dir role_path = "roles/invalid_due_to_meta" - result = run_ansible_lint(role_path, cwd=cwd) + result = run_ansible_lint(role_path, cwd=cwd, env={"NO_COLOR": "1"}) assert ( "role-name: Role name invalid-due-to-meta does not match" in strip_ansi_escape(result.stdout)