diff --git a/README.md b/README.md index 9d1d45b..2313be0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,22 @@ -# conda-rich +# Conda rich -Demonstration project utilizing the "rich" library for conda +This is a plugin for overriding certain elements of the conda CLI with components +from the rich library. To install and configure run the following commands: + +``` +conda install -c conda-forge conda-rich +conda config --set console rich +``` + +> [!TIP] +> You must be using conda at version 24.11 or higher to access this feature. + +This plugin currently overrides the following display components: + +- Loading spinner +- Download progress bars +- Confirmation dialogs +- The `conda info` display + +This plugin also serves as a demonstration of how to use the "reporter backend" +plugin hook. diff --git a/conda_rich/hooks.py b/conda_rich/hooks.py index d7f9701..0007d7b 100644 --- a/conda_rich/hooks.py +++ b/conda_rich/hooks.py @@ -64,7 +64,7 @@ def envs_list(self, data, **kwargs) -> str: console = Console(file=sys.stdout) with console.capture() as capture: - console.print("Enviroments") + console.print("Environments") console.print(data) return capture.get() @@ -112,11 +112,13 @@ def __init__( self, description: str, context_manager=None, + visible_when_finished=False, **kwargs, ) -> None: super().__init__(description) self.progress: Progress | None = None + self.visible_when_finished = visible_when_finished if isinstance(context_manager, Progress): self.progress = context_manager @@ -134,7 +136,7 @@ def update_to(self, fraction) -> None: self.progress.update(self.task, completed=fraction) if fraction == 1: - self.progress.update(self.task, visible=False) + self.progress.update(self.task, visible=self.visible_when_finished) def close(self) -> None: if self.progress is not None: @@ -178,7 +180,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_val, exc_tb): if exc_type or exc_val: - sys.stdout.write(self.fail_message) + sys.stdout.write(f"{self.fail_message}\n") else: sys.stdout.write("done\n") sys.stdout.flush() diff --git a/environment.yml b/environment.yml index da2d4d1..0712806 100644 --- a/environment.yml +++ b/environment.yml @@ -4,3 +4,4 @@ channels: dependencies: - python>=3.8 - conda-canary/label/dev::conda + - rich diff --git a/pyproject.toml b/pyproject.toml index 82c8e2a..b94afb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,45 @@ [build-system] -requires = ["setuptools>=61.0", "setuptools-scm"] -build-backend = "setuptools.build_meta" +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" [project] name = "conda-rich" -version = "0.1.0" -description = "Demonstration project utilizing the \"rich\" library for conda" +dynamic = ["version"] +description = "Conda plugin which uses rich components for its display" requires-python = ">=3.8" +license = {file = "LICENSE"} classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", "Operating System :: OS Independent", + "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy" ] dependencies = [ "conda", + "rich" ] [project.entry-points.conda] -conda-rich = "conda_rich.hooks" +conda-rich = "conda_rich.plugin" -[tool.setuptools] -packages = ["conda_rich"] +[tool.setuptools.packages] +find = {} + +[tool.hatch.version] +source = "vcs" [tool.pixi.project] channels = ["conda-forge"] -platforms = ["osx-arm64"] +platforms = ["osx-arm64", "osx-64", "linux-64", "win-64"] [tool.pixi.dependencies] conda = {channel = "conda-canary/label/dev"} diff --git a/tests/test_hooks.py b/tests/test_hooks.py new file mode 100644 index 0000000..ad55c24 --- /dev/null +++ b/tests/test_hooks.py @@ -0,0 +1,236 @@ +import time +from io import StringIO + +import pytest + +from conda.exceptions import CondaError +from conda.plugins import CondaReporterBackend +from rich.progress import Progress + +from conda_rich.hooks import ( + QuietProgressBar, + QuietSpinner, + RichSpinner, + RichProgressBar, + conda_reporter_backends, + RichReporterRenderer, +) + + +def test_conda_reporter_backends(): + """ + Ensure that a ``CondaReporterBackend`` object is yielded from the ``conda_reporter_backends`` + function + """ + hook_obj = next(conda_reporter_backends()) + + assert hook_obj is not None + assert isinstance(hook_obj, CondaReporterBackend) + assert hook_obj.name == "rich" + assert hook_obj.description == "Rich implementation for console reporting in conda" + assert hook_obj.renderer is RichReporterRenderer + + +def test_quiet_progress_bar(capsys): + """ + Basic test to ensure the quiet progress bar works as expected + """ + description = "Test" + quiet_progress = QuietProgressBar(description) + + capture = capsys.readouterr() + + assert capture.out == f"...downloading {description}...\n" + + # Test individual methods; these should do nothing and return None + assert quiet_progress.refresh() is None + assert quiet_progress.close() is None + assert quiet_progress.update_to(1) is None + + +def test_rich_progress_bar(capsys): + """ + Basic test to ensure the rich progress bar works as expected + """ + description = "Test" + units = 0 + + with Progress() as progress: + rich_progress = RichProgressBar( + description, context_manager=progress, visible_when_finished=True + ) + + while units < 5: + units += 1 + rich_progress.update_to(units / 4) + rich_progress.refresh() + time.sleep(0.01) + + rich_progress.close() + + capture = capsys.readouterr() + + assert capture.out == "Test ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 0:00:00\n" + + +def test_rich_progress_bar_misconfigured(): + """ + Ensure an exception is raised when we fail to pass a ``rich.progress.Progress`` + bar object to ``RichProgressBar``. + """ + with pytest.raises( + CondaError, match="Rich is configured, but there is no progress bar available" + ): + RichProgressBar("Test description", None) + + +def test_quiet_spinner(capsys): + """ + Basic test to ensure the quiet spinner works as expected + """ + message = "test" + with QuietSpinner(message): + pass + + capture = capsys.readouterr() + + assert capture.out == f"{message}: ...working... done\n" + + +def test_quiet_spinner_failed_state(capsys): + """ + Basic test to ensure the quiet spinner works as expected + """ + message = "test" + fail_message = "testing fail message" + + try: + with QuietSpinner(message, fail_message=fail_message): + raise Exception("Test fail") + + except Exception: + capture = capsys.readouterr() + assert capture.out == f"{message}: ...working... {fail_message}\n" + + +def test_rich_spinner(capsys): + """ + Basic test to ensure the rich spinner works as expected + """ + message = "test" + with RichSpinner(message): + pass + + capture = capsys.readouterr() + + assert capture.out == "test (done)\n" + + +def test_rich_reporter_renderer_detail_view(): + """ + Basic test to ensure that the ``detail_view`` method on the ``RichReporterRenderer`` class + works as expected + """ + renderer = RichReporterRenderer() + + data = { + "field_one": "one", + "field_two": "two", + } + + render_str = renderer.detail_view(data) + + assert render_str == "\n field_one : one\n field_two : two\n\n" + + +def test_rich_reporter_renderer_env_list(): + """ + Basic test to ensure that the ``env_list`` method on the ``RichReporterRenderer`` class + works as expected + """ + renderer = RichReporterRenderer() + environments = ["one", "two", "three"] + + render_str = renderer.envs_list(environments) + + assert render_str == "Environments\n['one', 'two', 'three']\n" + + +def test_rich_reporter_renderer_progress_bar(mocker): + """ + Basic test to ensure that the ``progress_bar`` method on the ``RichReporterRenderer`` class + works as expected + """ + mock_context = mocker.patch("conda_rich.hooks.context") + mock_context.quiet = False + + renderer = RichReporterRenderer() + progress = Progress() + progress_bar = renderer.progress_bar("test", context_manager=progress) + + assert isinstance(progress_bar, RichProgressBar) + + +def test_rich_reporter_renderer_progress_bar_with_quiet(mocker): + """ + Basic test to ensure that the ``progress_bar`` method on the ``RichReporterRenderer`` class + works as expected when ``context.quiet`` is ``True`` + """ + mock_context = mocker.patch("conda_rich.hooks.context") + mock_context.quiet = True + + renderer = RichReporterRenderer() + progress_bar = renderer.progress_bar("test") + + assert isinstance(progress_bar, QuietProgressBar) + + +def test_rich_reporter_renderer_progress_bar_context_manager(): + """ + Basic test to ensure that the ``progress_bar_context_manager`` method on the + ``RichReporterRenderer`` class works as expected + """ + renderer = RichReporterRenderer() + + # Make sure that the item the context manager returns is a ``rich.progress.Progress`` instance + with renderer.progress_bar_context_manager() as ctx: + assert isinstance(ctx, Progress) + + +def test_rich_reporter_renderer_spinner(mocker): + """ + Basic test to ensure that the ``spinner`` method on the ``RichReporterRenderer`` class + works as expected + """ + mock_context = mocker.patch("conda_rich.hooks.context") + mock_context.quiet = False + + renderer = RichReporterRenderer() + spinner = renderer.spinner("test message") + + assert isinstance(spinner, RichSpinner) + + +def test_rich_reporter_renderer_spinner_with_quiet(mocker): + """ + Basic test to ensure that the ``spinner`` method on the ``RichReporterRenderer`` class + works as expected when ``context.quiet`` is ``True`` + """ + mock_context = mocker.patch("conda_rich.hooks.context") + mock_context.quiet = True + + renderer = RichReporterRenderer() + spinner = renderer.spinner("test") + + assert isinstance(spinner, QuietSpinner) + + +def test_rich_reporter_renderer_prompt(monkeypatch, capsys): + """ + Basic test to ensure that the ``prompt`` method on the ``RichReporterRenderer`` class + works as expected + """ + renderer = RichReporterRenderer() + monkeypatch.setattr("sys.stdin", StringIO("yes\n")) + + assert renderer.prompt() == "yes" diff --git a/tests/test_placeholder.py b/tests/test_placeholder.py deleted file mode 100644 index 0443420..0000000 --- a/tests/test_placeholder.py +++ /dev/null @@ -1,4 +0,0 @@ - - -def test_placeholder(): - assert 1 + 1 == 2