diff --git a/src/rich_cli/__main__.py b/src/rich_cli/__main__.py index 98df6bb..a72205c 100644 --- a/src/rich_cli/__main__.py +++ b/src/rich_cli/__main__.py @@ -290,7 +290,10 @@ class OptionHighlighter(RegexHighlighter): "--text-full", "-F", is_flag=True, help="Justify text to both left and right edges." ) @click.option( - "--soft", is_flag=True, help="Enable soft wrapping of text (requires --print)." + "--soft", + is_flag=True, + envvar="RICH_SOFT_WRAP", + help="Enable soft wrapping of text. Can also be set via RICH_SOFT_WRAP env var.", ) @click.option( "--expand", "-e", is_flag=True, help="Expand to full width (requires --panel)." @@ -300,7 +303,8 @@ class OptionHighlighter(RegexHighlighter): "-w", metavar="SIZE", type=int, - help="Fit output to [b]SIZE[/] characters.", + envvar="RICH_WIDTH", + help="Fit output to [b]SIZE[/] characters. Can also be set via RICH_WIDTH env var.", default=-1, ) @click.option( @@ -355,13 +359,18 @@ class OptionHighlighter(RegexHighlighter): envvar="RICH_THEME", ) @click.option( - "--line-numbers", "-n", is_flag=True, help="Enable line number in syntax." + "--line-numbers", + "-n", + is_flag=True, + envvar="RICH_LINE_NUMBERS", + help="Enable line number in syntax. Can also be set via RICH_LINE_NUMBERS env var.", ) @click.option( "--guides", "-g", is_flag=True, - help="Enable indentation guides in syntax highlighting", + envvar="RICH_GUIDES", + help="Enable indentation guides in syntax highlighting. Can also be set via RICH_GUIDES env var.", ) @click.option( "--lexer", @@ -370,7 +379,13 @@ class OptionHighlighter(RegexHighlighter): default=None, help="Use [b]LEXER[/b] for syntax highlighting. [dim]See https://pygments.org/docs/lexers/", ) -@click.option("--hyperlinks", "-y", is_flag=True, help="Render hyperlinks in markdown.") +@click.option( + "--hyperlinks", + "-y", + is_flag=True, + envvar="RICH_HYPERLINKS", + help="Render hyperlinks in markdown. Can also be set via RICH_HYPERLINKS env var.", +) @click.option( "--no-wrap", is_flag=True, help="Don't word wrap syntax highlighted files." ) @@ -383,7 +398,8 @@ class OptionHighlighter(RegexHighlighter): @click.option( "--force-terminal", is_flag=True, - help="Force terminal output when not writing to a terminal.", + envvar="RICH_FORCE_TERMINAL", + help="Force terminal output when not writing to a terminal. Can also be set via RICH_FORCE_TERMINAL env var.", ) @click.option( "--export-html", @@ -395,7 +411,12 @@ class OptionHighlighter(RegexHighlighter): @click.option( "--export-svg", metavar="PATH", default="", help="Write SVG to [b]PATH[/b]." ) -@click.option("--pager", is_flag=True, help="Display in an interactive pager.") +@click.option( + "--pager", + is_flag=True, + envvar="RICH_PAGER", + help="Display in an interactive pager. Can also be set via RICH_PAGER env var.", +) @click.option("--version", "-v", is_flag=True, help="Print version and exit.") def main( resource: str, diff --git a/tests/test_env_vars.py b/tests/test_env_vars.py new file mode 100644 index 0000000..827ef4c --- /dev/null +++ b/tests/test_env_vars.py @@ -0,0 +1,196 @@ +"""Tests for environment variable support in rich-cli.""" + +import os +from unittest import mock + +import pytest +from click.testing import CliRunner + +from rich_cli.__main__ import main + + +class TestEnvironmentVariables: + """Test suite for environment variable functionality.""" + + @pytest.fixture + def runner(self): + """Create a Click test runner.""" + return CliRunner() + + @pytest.fixture + def sample_python_file(self, tmp_path): + """Create a sample Python file for testing.""" + file_path = tmp_path / "sample.py" + file_path.write_text( + "def hello():\n" + " print('Hello, World!')\n" + "\n" + "if __name__ == '__main__':\n" + " hello()\n" + ) + return str(file_path) + + @pytest.fixture + def sample_markdown_file(self, tmp_path): + """Create a sample Markdown file for testing.""" + file_path = tmp_path / "sample.md" + file_path.write_text( + "# Hello World\n\n" + "This is a [link](https://example.com).\n" + ) + return str(file_path) + + # ==================== RICH_HYPERLINKS Tests ==================== + + def test_hyperlinks_env_var_enables_hyperlinks( + self, runner, sample_markdown_file + ): + """Test that RICH_HYPERLINKS=1 enables hyperlinks.""" + with mock.patch.dict(os.environ, {"RICH_HYPERLINKS": "1"}): + result = runner.invoke( + main, [sample_markdown_file, "--markdown"] + ) + assert result.exit_code == 0 + + def test_hyperlinks_env_var_various_truthy_values(self, runner): + """Test that various truthy values work for RICH_HYPERLINKS.""" + truthy_values = ["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"] + + for value in truthy_values: + with mock.patch.dict(os.environ, {"RICH_HYPERLINKS": value}): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0, f"Failed for value: {value}" + + def test_hyperlinks_env_var_falsy_values(self, runner): + """Test that falsy values disable hyperlinks.""" + falsy_values = ["0", "false", "False", "no", "off", ""] + + for value in falsy_values: + with mock.patch.dict(os.environ, {"RICH_HYPERLINKS": value}): + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0, f"Failed for value: {value}" + + def test_hyperlinks_cli_overrides_env_var(self, runner, sample_markdown_file): + """Test that CLI flag overrides environment variable.""" + with mock.patch.dict(os.environ, {"RICH_HYPERLINKS": "1"}): + result = runner.invoke( + main, [sample_markdown_file, "--markdown"] + ) + assert result.exit_code == 0 + + # ==================== RICH_LINE_NUMBERS Tests ==================== + + def test_line_numbers_env_var(self, runner, sample_python_file): + """Test that RICH_LINE_NUMBERS enables line numbers.""" + with mock.patch.dict(os.environ, {"RICH_LINE_NUMBERS": "1"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + def test_line_numbers_env_var_disabled(self, runner, sample_python_file): + """Test that RICH_LINE_NUMBERS=0 disables line numbers.""" + with mock.patch.dict(os.environ, {"RICH_LINE_NUMBERS": "0"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + # ==================== RICH_GUIDES Tests ==================== + + def test_guides_env_var(self, runner, sample_python_file): + """Test that RICH_GUIDES enables indent guides.""" + with mock.patch.dict(os.environ, {"RICH_GUIDES": "1"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + # ==================== RICH_FORCE_TERMINAL Tests ==================== + + def test_force_terminal_env_var(self, runner, sample_python_file): + """Test that RICH_FORCE_TERMINAL forces terminal output.""" + with mock.patch.dict(os.environ, {"RICH_FORCE_TERMINAL": "1"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + # ==================== RICH_SOFT_WRAP Tests ==================== + + def test_soft_wrap_env_var(self, runner, sample_python_file): + """Test that RICH_SOFT_WRAP enables soft wrapping.""" + with mock.patch.dict(os.environ, {"RICH_SOFT_WRAP": "1"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + # ==================== RICH_PAGER Tests ==================== + + def test_pager_env_var(self, runner, sample_python_file): + """Test that RICH_PAGER env var is recognized.""" + # Test with pager disabled to avoid interactive mode + with mock.patch.dict(os.environ, {"RICH_PAGER": "0"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + # ==================== RICH_WIDTH Tests ==================== + + def test_width_env_var(self, runner, sample_python_file): + """Test that RICH_WIDTH sets output width.""" + with mock.patch.dict(os.environ, {"RICH_WIDTH": "80"}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + def test_width_env_var_various_values(self, runner, sample_python_file): + """Test various width values.""" + for width in ["40", "80", "120", "200"]: + with mock.patch.dict(os.environ, {"RICH_WIDTH": width}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0, f"Failed for width: {width}" + + def test_width_invalid_value(self, runner, sample_python_file): + """Test that invalid width values cause an error.""" + with mock.patch.dict(os.environ, {"RICH_WIDTH": "not_a_number"}): + result = runner.invoke(main, [sample_python_file]) + # Click should raise an error for invalid int conversion + assert result.exit_code != 0 + + # ==================== Combined Tests ==================== + + def test_multiple_env_vars_combined(self, runner, sample_python_file): + """Test multiple environment variables working together.""" + env_vars = { + "RICH_LINE_NUMBERS": "1", + "RICH_GUIDES": "1", + "RICH_WIDTH": "100", + } + with mock.patch.dict(os.environ, env_vars): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + def test_env_vars_with_existing_rich_theme(self, runner, sample_python_file): + """Test new env vars work alongside existing RICH_THEME.""" + env_vars = { + "RICH_THEME": "monokai", + "RICH_LINE_NUMBERS": "1", + "RICH_GUIDES": "1", + } + with mock.patch.dict(os.environ, env_vars): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + def test_unset_env_vars_use_defaults(self, runner, sample_python_file): + """Test that unset env vars fall back to defaults.""" + env_vars_to_remove = [ + "RICH_HYPERLINKS", + "RICH_LINE_NUMBERS", + "RICH_GUIDES", + "RICH_FORCE_TERMINAL", + "RICH_SOFT_WRAP", + "RICH_PAGER", + "RICH_WIDTH", + ] + clean_env = {k: v for k, v in os.environ.items() + if k not in env_vars_to_remove} + + with mock.patch.dict(os.environ, clean_env, clear=True): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0 + + def test_empty_string_env_var_treated_as_false(self, runner, sample_python_file): + """Test that empty string env vars are treated as falsy.""" + with mock.patch.dict(os.environ, {"RICH_LINE_NUMBERS": ""}): + result = runner.invoke(main, [sample_python_file]) + assert result.exit_code == 0