From 876e9ea9c34239d66ebe4ecc122caf5232a0a910 Mon Sep 17 00:00:00 2001 From: Karan Gathani Date: Tue, 20 Jan 2026 22:15:28 -0800 Subject: [PATCH 01/31] Add unit and pytest tests, coverage config, and Makefile targets Added extensive unit and pytest-based test coverage for various modules under tests/pytest and tests/unit. Introduced new Makefile targets for unit test coverage and coverage threshold checking. Updated pyproject.toml to include coverage.py configuration for source, reporting, and HTML output. --- Makefile | 7 + pyproject.toml | 27 + tests/pytest/test_css_unit.py | 149 ++ tests/pytest/test_deprecated.py | 140 ++ tests/pytest/test_docstring.py | 357 +++++ tests/pytest/test_hostenv.py | 304 +++++ tests/pytest/test_html_dependencies.py | 118 ++ tests/pytest/test_include_helpers.py | 274 ++++ tests/pytest/test_input_action_button.py | 92 ++ tests/pytest/test_input_handler.py | 292 ++++ tests/pytest/test_plot_output_opts.py | 164 +++ tests/pytest/test_plotutils.py | 305 +++++ tests/pytest/test_render__render.py | 582 ++++++++ tests/pytest/test_session__session.py | 1570 ++++++++++++++++++++++ tests/pytest/test_shiny_utils.py | 332 +++++ tests/pytest/test_types.py | 325 +++++ tests/pytest/test_ui_accordion.py | 240 ++++ tests/pytest/test_ui_bootstrap.py | 236 ++++ tests/pytest/test_ui_card.py | 278 ++++ tests/pytest/test_ui_fill.py | 189 +++ tests/pytest/test_ui_layout.py | 214 +++ tests/pytest/test_ui_markdown.py | 114 ++ tests/pytest/test_ui_modal.py | 173 +++ tests/pytest/test_ui_output.py | 228 ++++ tests/pytest/test_ui_popover.py | 138 ++ tests/pytest/test_ui_sidebar.py | 199 +++ tests/pytest/test_ui_tag.py | 90 ++ tests/pytest/test_ui_tooltip.py | 118 ++ tests/pytest/test_ui_utils.py | 276 ++++ tests/pytest/test_ui_valuebox.py | 309 +++++ tests/pytest/test_validation.py | 130 ++ tests/pytest/test_web_component.py | 35 + tests/unit/test_accordion.py | 235 ++++ tests/unit/test_bootstrap.py | 337 +++++ tests/unit/test_card.py | 298 ++++ tests/unit/test_css_unit.py | 105 ++ tests/unit/test_download_button.py | 165 +++ tests/unit/test_fill.py | 138 ++ tests/unit/test_input_action_button.py | 132 ++ tests/unit/test_input_check_radio.py | 470 +++++++ tests/unit/test_input_code_editor.py | 539 ++++++++ tests/unit/test_input_date.py | 351 +++++ tests/unit/test_input_file.py | 233 ++++ tests/unit/test_input_numeric.py | 188 +++ tests/unit/test_input_password.py | 154 +++ tests/unit/test_input_select.py | 300 +++++ tests/unit/test_input_slider.py | 292 ++++ tests/unit/test_input_text.py | 372 +++++ tests/unit/test_input_update.py | 915 +++++++++++++ tests/unit/test_layout.py | 212 +++ tests/unit/test_layout_columns.py | 143 ++ tests/unit/test_markdown.py | 366 +++++ tests/unit/test_modal.py | 246 ++++ tests/unit/test_navs.py | 357 +++++ tests/unit/test_notification.py | 300 +++++ tests/unit/test_output.py | 309 +++++ tests/unit/test_page.py | 152 +++ tests/unit/test_plot_output_opts.py | 178 +++ tests/unit/test_popover.py | 140 ++ tests/unit/test_progress.py | 334 +++++ tests/unit/test_shiny_utils.py | 222 +++ tests/unit/test_sidebar.py | 244 ++++ tests/unit/test_tag.py | 90 ++ tests/unit/test_theme.py | 569 ++++++++ tests/unit/test_theme_brand.py | 836 ++++++++++++ tests/unit/test_toast.py | 461 +++++++ tests/unit/test_tooltip.py | 141 ++ tests/unit/test_valuebox.py | 289 ++++ 68 files changed, 18818 insertions(+) create mode 100644 tests/pytest/test_css_unit.py create mode 100644 tests/pytest/test_deprecated.py create mode 100644 tests/pytest/test_docstring.py create mode 100644 tests/pytest/test_hostenv.py create mode 100644 tests/pytest/test_html_dependencies.py create mode 100644 tests/pytest/test_include_helpers.py create mode 100644 tests/pytest/test_input_action_button.py create mode 100644 tests/pytest/test_input_handler.py create mode 100644 tests/pytest/test_plot_output_opts.py create mode 100644 tests/pytest/test_plotutils.py create mode 100644 tests/pytest/test_render__render.py create mode 100644 tests/pytest/test_session__session.py create mode 100644 tests/pytest/test_shiny_utils.py create mode 100644 tests/pytest/test_types.py create mode 100644 tests/pytest/test_ui_accordion.py create mode 100644 tests/pytest/test_ui_bootstrap.py create mode 100644 tests/pytest/test_ui_card.py create mode 100644 tests/pytest/test_ui_fill.py create mode 100644 tests/pytest/test_ui_layout.py create mode 100644 tests/pytest/test_ui_markdown.py create mode 100644 tests/pytest/test_ui_modal.py create mode 100644 tests/pytest/test_ui_output.py create mode 100644 tests/pytest/test_ui_popover.py create mode 100644 tests/pytest/test_ui_sidebar.py create mode 100644 tests/pytest/test_ui_tag.py create mode 100644 tests/pytest/test_ui_tooltip.py create mode 100644 tests/pytest/test_ui_utils.py create mode 100644 tests/pytest/test_ui_valuebox.py create mode 100644 tests/pytest/test_validation.py create mode 100644 tests/pytest/test_web_component.py create mode 100644 tests/unit/test_accordion.py create mode 100644 tests/unit/test_bootstrap.py create mode 100644 tests/unit/test_card.py create mode 100644 tests/unit/test_css_unit.py create mode 100644 tests/unit/test_download_button.py create mode 100644 tests/unit/test_fill.py create mode 100644 tests/unit/test_input_action_button.py create mode 100644 tests/unit/test_input_check_radio.py create mode 100644 tests/unit/test_input_code_editor.py create mode 100644 tests/unit/test_input_date.py create mode 100644 tests/unit/test_input_file.py create mode 100644 tests/unit/test_input_numeric.py create mode 100644 tests/unit/test_input_password.py create mode 100644 tests/unit/test_input_select.py create mode 100644 tests/unit/test_input_slider.py create mode 100644 tests/unit/test_input_text.py create mode 100644 tests/unit/test_input_update.py create mode 100644 tests/unit/test_layout.py create mode 100644 tests/unit/test_layout_columns.py create mode 100644 tests/unit/test_markdown.py create mode 100644 tests/unit/test_modal.py create mode 100644 tests/unit/test_navs.py create mode 100644 tests/unit/test_notification.py create mode 100644 tests/unit/test_output.py create mode 100644 tests/unit/test_page.py create mode 100644 tests/unit/test_plot_output_opts.py create mode 100644 tests/unit/test_popover.py create mode 100644 tests/unit/test_progress.py create mode 100644 tests/unit/test_shiny_utils.py create mode 100644 tests/unit/test_sidebar.py create mode 100644 tests/unit/test_tag.py create mode 100644 tests/unit/test_theme.py create mode 100644 tests/unit/test_theme_brand.py create mode 100644 tests/unit/test_toast.py create mode 100644 tests/unit/test_tooltip.py create mode 100644 tests/unit/test_valuebox.py diff --git a/Makefile b/Makefile index fc8d03a84..ad76b9683 100644 --- a/Makefile +++ b/Makefile @@ -227,6 +227,13 @@ coverage: FORCE ## check combined code coverage (must run e2e last) coverage html $(BROWSER) htmlcov/index.html +coverage-unit: FORCE ## check unit test coverage only + pytest tests/pytest/ --cov=shiny --cov-report=term-missing --cov-report=html + @echo "Coverage report: htmlcov/index.html" + +coverage-check: FORCE ## check coverage meets minimum threshold (25%) + pytest tests/pytest/ --cov=shiny --cov-fail-under=25 + release: dist ## package and upload a release twine upload dist/* diff --git a/pyproject.toml b/pyproject.toml index 8115cfa1a..ed05acc3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -174,3 +174,30 @@ skip = [ # Note: This setting can not be done via CLI and must be set within a config ignore_errors = true exclude = ["shiny/api-examples", "shiny/templates"] + +[tool.coverage.run] +source = ["shiny"] +branch = true +omit = [ + "shiny/api-examples/*", + "shiny/templates/*", + "shiny/www/*", + "shiny/_version.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if typing.TYPE_CHECKING:", + "@overload", + "@typing.overload", + "\\.\\.\\.", # Ellipsis in stub files +] +show_missing = true +precision = 2 + +[tool.coverage.html] +directory = "htmlcov" diff --git a/tests/pytest/test_css_unit.py b/tests/pytest/test_css_unit.py new file mode 100644 index 000000000..1449da23d --- /dev/null +++ b/tests/pytest/test_css_unit.py @@ -0,0 +1,149 @@ +"""Tests for shiny/ui/css/_css_unit.py""" + +from __future__ import annotations + +from shiny.ui.css._css_unit import ( + as_css_padding, + as_css_unit, + as_grid_unit, + isinstance_cssunit, +) + + +class TestAsCssUnit: + """Tests for the as_css_unit function.""" + + def test_none_returns_none(self) -> None: + """Test that None returns None.""" + assert as_css_unit(None) is None + + def test_zero_returns_zero_string(self) -> None: + """Test that 0 returns '0'.""" + assert as_css_unit(0) == "0" + assert as_css_unit(0.0) == "0" + + def test_integer_returns_pixels(self) -> None: + """Test that integers return pixel values.""" + result = as_css_unit(100) + assert result.endswith("px") + assert "100" in result + + def test_float_returns_pixels(self) -> None: + """Test that floats return pixel values.""" + result = as_css_unit(100.5) + assert result.endswith("px") + assert "100" in result + + def test_string_passes_through(self) -> None: + """Test that strings pass through unchanged.""" + assert as_css_unit("1em") == "1em" + assert as_css_unit("50%") == "50%" + assert as_css_unit("calc(100% - 20px)") == "calc(100% - 20px)" + + def test_negative_integer(self) -> None: + """Test negative integer values.""" + result = as_css_unit(-10) + assert result.endswith("px") + assert "-10" in result + + +class TestAsCssPadding: + """Tests for the as_css_padding function.""" + + def test_none_returns_none(self) -> None: + """Test that None returns None.""" + assert as_css_padding(None) is None + + def test_single_value(self) -> None: + """Test single CSS unit value.""" + assert as_css_padding("1em") == "1em" + assert "10" in as_css_padding(10) + assert as_css_padding(0) == "0" + + def test_list_of_values(self) -> None: + """Test list of CSS unit values.""" + result = as_css_padding(["1em", "2em"]) + assert result == "1em 2em" + + def test_list_with_mixed_types(self) -> None: + """Test list with mixed types.""" + result = as_css_padding([10, "1em", 0, "2%"]) + assert "1em" in result + assert "0" in result + assert "2%" in result + assert "px" in result + + def test_four_values(self) -> None: + """Test four-value padding (top, right, bottom, left).""" + result = as_css_padding(["1em", "2em", "3em", "4em"]) + assert result == "1em 2em 3em 4em" + + +class TestIsinstanceCssunit: + """Tests for the isinstance_cssunit function.""" + + def test_int_is_cssunit(self) -> None: + """Test that int is a CssUnit.""" + assert isinstance_cssunit(10) is True + + def test_float_is_cssunit(self) -> None: + """Test that float is a CssUnit.""" + assert isinstance_cssunit(10.5) is True + + def test_str_is_cssunit(self) -> None: + """Test that str is a CssUnit.""" + assert isinstance_cssunit("1em") is True + + def test_none_is_not_cssunit(self) -> None: + """Test that None is not a CssUnit.""" + assert isinstance_cssunit(None) is False + + def test_list_is_not_cssunit(self) -> None: + """Test that list is not a CssUnit.""" + assert isinstance_cssunit([1, 2]) is False + + +class TestAsGridUnit: + """Tests for the as_grid_unit function.""" + + def test_none_returns_none(self) -> None: + """Test that None returns None.""" + assert as_grid_unit(None) is None + + def test_integer_returns_pixels(self) -> None: + """Test that integers return pixel values.""" + result = as_grid_unit(100) + assert result is not None + assert result.endswith("px") + + def test_auto_keyword(self) -> None: + """Test 'auto' keyword.""" + assert as_grid_unit("auto") == "auto" + assert as_grid_unit("AUTO") == "auto" + assert as_grid_unit("Auto") == "auto" + + def test_min_content_keyword(self) -> None: + """Test 'min-content' keyword.""" + assert as_grid_unit("min-content") == "min-content" + assert as_grid_unit("MIN-CONTENT") == "min-content" + + def test_max_content_keyword(self) -> None: + """Test 'max-content' keyword.""" + assert as_grid_unit("max-content") == "max-content" + assert as_grid_unit("MAX-CONTENT") == "max-content" + + def test_minmax_function(self) -> None: + """Test minmax() function passthrough.""" + result = as_grid_unit("minmax(100px, 1fr)") + assert result == "minmax(100px, 1fr)" + + def test_fr_unit(self) -> None: + """Test fr unit handling.""" + result = as_grid_unit("1fr") + # fr units get passed through as_css_unit which should return them as-is + assert result == "1fr" + + def test_regular_css_unit(self) -> None: + """Test regular CSS unit passthrough.""" + assert as_grid_unit("50%") == "50%" + assert as_grid_unit("10em") == "10em" diff --git a/tests/pytest/test_deprecated.py b/tests/pytest/test_deprecated.py new file mode 100644 index 000000000..649f23bbd --- /dev/null +++ b/tests/pytest/test_deprecated.py @@ -0,0 +1,140 @@ +"""Tests for shiny/_deprecated.py""" + +from __future__ import annotations + +import warnings + +import pytest + +from shiny._deprecated import ( + ShinyDeprecationWarning, + event, + render_image, + render_plot, + render_text, + render_ui, + warn_deprecated, +) + + +class TestShinyDeprecationWarning: + """Tests for the ShinyDeprecationWarning class.""" + + def test_is_runtime_warning(self) -> None: + """Test that ShinyDeprecationWarning is a RuntimeWarning.""" + assert issubclass(ShinyDeprecationWarning, RuntimeWarning) + + def test_can_be_raised(self) -> None: + """Test that ShinyDeprecationWarning can be raised.""" + with pytest.raises(ShinyDeprecationWarning): + raise ShinyDeprecationWarning("test message") + + +class TestWarnDeprecated: + """Tests for the warn_deprecated function.""" + + def test_issues_shiny_deprecation_warning(self) -> None: + """Test that warn_deprecated issues ShinyDeprecationWarning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("test deprecation message", stacklevel=1) + + assert len(w) == 1 + assert issubclass(w[0].category, ShinyDeprecationWarning) + assert "test deprecation message" in str(w[0].message) + + def test_custom_stacklevel(self) -> None: + """Test that warn_deprecated uses custom stacklevel.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + warn_deprecated("test message", stacklevel=2) + + assert len(w) == 1 + + +class TestDeprecatedRenderFunctions: + """Tests for deprecated render functions.""" + + def test_render_text_shows_deprecation_warning(self) -> None: + """Test that render_text() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_text() + + # Check that we got a warning + assert any( + "render_text() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + def test_render_ui_shows_deprecation_warning(self) -> None: + """Test that render_ui() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_ui() + + # Check that we got a warning + assert any( + "render_ui() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + def test_render_plot_shows_deprecation_warning(self) -> None: + """Test that render_plot() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_plot() + + # Check that we got a warning + assert any( + "render_plot() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + def test_render_image_shows_deprecation_warning(self) -> None: + """Test that render_image() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + decorator = render_image() + + # Check that we got a warning + assert any( + "render_image() is deprecated" in str(warning.message) for warning in w + ) + # Check that we got a decorator function + assert callable(decorator) + + +class TestDeprecatedEvent: + """Tests for deprecated event decorator.""" + + def test_event_shows_deprecation_warning(self) -> None: + """Test that event() shows deprecation warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + # event() requires at least one reactive dependency + from shiny import reactive + + rv = reactive.value(0) + + # Create event decorator with a dependency + _ = event(rv) + + # Check that we got a warning + assert any( + "@event() is deprecated" in str(warning.message) for warning in w + ) + + +class TestDeprecatedExports: + """Test that deprecated functions are properly exported.""" + + def test_all_exports_available(self) -> None: + """Test that all __all__ exports are available.""" + from shiny._deprecated import __all__ + + expected = ("render_text", "render_plot", "render_image", "render_ui", "event") + assert __all__ == expected diff --git a/tests/pytest/test_docstring.py b/tests/pytest/test_docstring.py new file mode 100644 index 000000000..e70c44c81 --- /dev/null +++ b/tests/pytest/test_docstring.py @@ -0,0 +1,357 @@ +"""Tests for shiny/_docstring.py - Docstring utilities and example handling.""" + +import os +import tempfile +from unittest.mock import patch + +import pytest + +from shiny._docstring import ( + DocStringWithExample, + ExampleNotFoundException, + ExampleWriter, + ExpressExampleNotFoundException, + app_choose_core_or_express, + doc_format, + find_api_examples_dir, + get_decorated_source_directory, + is_express_app, + no_example, +) + + +class TestFindApiExamplesDir: + """Tests for find_api_examples_dir function.""" + + def test_find_api_examples_dir_exists(self): + """Test finding api-examples directory when it exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create api-examples directory + api_examples_dir = os.path.join(tmpdir, "api-examples") + os.makedirs(api_examples_dir) + + result = find_api_examples_dir(tmpdir) + assert result == api_examples_dir + + def test_find_api_examples_dir_in_parent(self): + """Test finding api-examples directory in parent.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create api-examples in root + api_examples_dir = os.path.join(tmpdir, "api-examples") + os.makedirs(api_examples_dir) + + # Create a subdirectory + subdir = os.path.join(tmpdir, "subdir") + os.makedirs(subdir) + + result = find_api_examples_dir(subdir) + assert result == api_examples_dir + + def test_find_api_examples_dir_not_found(self): + """Test returns None when no api-examples directory exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create pyproject.toml to mark as root + with open(os.path.join(tmpdir, "pyproject.toml"), "w") as f: + f.write("") + + subdir = os.path.join(tmpdir, "subdir") + os.makedirs(subdir) + + result = find_api_examples_dir(subdir) + assert result is None + + def test_find_api_examples_dir_stops_at_root_files(self): + """Test stops searching at package root markers.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create setup.cfg to mark root + with open(os.path.join(tmpdir, "setup.cfg"), "w") as f: + f.write("") + + result = find_api_examples_dir(tmpdir) + assert result is None + + +class TestDocStringWithExample: + """Tests for DocStringWithExample class.""" + + def test_docstring_with_example_is_string(self): + """Test that DocStringWithExample is a string subclass.""" + doc = DocStringWithExample("This is a docstring") + assert isinstance(doc, str) + assert doc == "This is a docstring" + + def test_docstring_with_example_preserves_content(self): + """Test that content is preserved.""" + content = "Test docstring\n\nWith multiple lines." + doc = DocStringWithExample(content) + assert str(doc) == content + + +class TestExampleWriter: + """Tests for ExampleWriter class.""" + + def test_write_example_reads_file(self): + """Test that write_example reads file content.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App\napp = App()") + + writer = ExampleWriter() + result = writer.write_example([app_file]) + + assert "from shiny import App" in result + assert "app = App()" in result + assert "```.python" in result + + +class TestNoExample: + """Tests for no_example decorator.""" + + _no_example_attr_name = "__no_example" + + def test_no_example_sets_attribute(self): + """Test that no_example sets __no_example attribute.""" + + @no_example() + def my_func(): + pass + + assert hasattr(my_func, self._no_example_attr_name) + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "express" in no_example_attr + assert "core" in no_example_attr + + def test_no_example_express_only(self): + """Test no_example with express mode only.""" + + @no_example(mode="express") + def my_func(): + pass + + assert hasattr(my_func, self._no_example_attr_name) + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "express" in no_example_attr + assert "core" not in no_example_attr + + def test_no_example_core_only(self): + """Test no_example with core mode only.""" + + @no_example(mode="core") + def my_func(): + pass + + assert hasattr(my_func, self._no_example_attr_name) + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "core" in no_example_attr + assert "express" not in no_example_attr + + def test_no_example_multiple_applications(self): + """Test applying no_example multiple times.""" + + @no_example(mode="core") + @no_example(mode="express") + def my_func(): + pass + + no_example_attr = getattr(my_func, self._no_example_attr_name) + assert "express" in no_example_attr + assert "core" in no_example_attr + + +class TestIsExpressApp: + """Tests for is_express_app function.""" + + def test_is_express_app_with_from_import(self): + """Test detection of 'from shiny.express' import.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny.express import input, output") + + assert is_express_app(app_file) is True + + def test_is_express_app_with_import_statement(self): + """Test detection of 'import shiny.express' statement.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("import shiny.express") + + assert is_express_app(app_file) is True + + def test_is_express_app_core_app(self): + """Test that core app is not detected as express.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App, ui") + + assert is_express_app(app_file) is False + + def test_is_express_app_nonexistent(self): + """Test that nonexistent file returns False.""" + assert is_express_app("/nonexistent/path/app.py") is False + + +class TestExampleNotFoundException: + """Tests for ExampleNotFoundException class.""" + + def test_exception_message_single_file(self): + """Test exception message with single file name.""" + exc = ExampleNotFoundException("app.py", "/some/dir", "core") + msg = str(exc) + assert "app.py" in msg + assert "/some/dir" in msg + assert "Core" in msg + + def test_exception_message_multiple_files(self): + """Test exception message with multiple file names.""" + exc = ExampleNotFoundException(["app.py", "app-core.py"], "/some/dir", "core") + msg = str(exc) + assert "app.py" in msg + assert "app-core.py" in msg + assert " or " in msg + + def test_exception_message_express_type(self): + """Test exception message with express type.""" + exc = ExampleNotFoundException("app.py", "/some/dir", "express") + msg = str(exc) + assert "Express" in msg + + +class TestExpressExampleNotFoundException: + """Tests for ExpressExampleNotFoundException class.""" + + def test_is_example_not_found_subclass(self): + """Test that it's a subclass of ExampleNotFoundException.""" + exc = ExpressExampleNotFoundException("app.py", "/dir") + assert isinstance(exc, ExampleNotFoundException) + + def test_type_is_express(self): + """Test that type is always express.""" + exc = ExpressExampleNotFoundException("app.py", "/dir") + assert exc.type == "express" + + +class TestAppChooseCoreOrExpress: + """Tests for app_choose_core_or_express function.""" + + def test_returns_existing_express_app(self): + """Test returns express app when it exists and is express.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny.express import input") + + with patch.dict(os.environ, {"SHINY_MODE": "express"}): + result = app_choose_core_or_express(app_file, mode="express") + assert result == app_file + + def test_returns_express_variant(self): + """Test returns app-express.py when main file is not express.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create core app.py + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App") + + # Create express variant + express_file = os.path.join(tmpdir, "app-express.py") + with open(express_file, "w") as f: + f.write("from shiny.express import input") + + result = app_choose_core_or_express(app_file, mode="express") + assert result == express_file + + def test_returns_core_app(self): + """Test returns core app when mode is core.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App") + + result = app_choose_core_or_express(app_file, mode="core") + assert result == app_file + + def test_falls_back_to_app_core(self): + """Test falls back to app-core.py when app.py doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Only create app-core.py + core_file = os.path.join(tmpdir, "app-core.py") + with open(core_file, "w") as f: + f.write("from shiny import App") + + app_file = os.path.join(tmpdir, "app.py") + result = app_choose_core_or_express(app_file, mode="core") + assert result == core_file + + def test_raises_when_file_not_found(self): + """Test raises ExampleNotFoundException when file not found.""" + with tempfile.TemporaryDirectory() as tmpdir: + app_file = os.path.join(tmpdir, "app.py") + with pytest.raises(ExampleNotFoundException): + app_choose_core_or_express(app_file, mode="core") + + def test_raises_express_not_found(self): + """Test raises ExpressExampleNotFoundException for missing express.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create core app only + app_file = os.path.join(tmpdir, "app.py") + with open(app_file, "w") as f: + f.write("from shiny import App") + + with pytest.raises(ExpressExampleNotFoundException): + app_choose_core_or_express(app_file, mode="express") + + +class TestDocFormat: + """Tests for doc_format decorator.""" + + def test_doc_format_substitutes_values(self): + """Test that doc_format substitutes placeholders.""" + + @doc_format(name="TestFunc", param="value") + def my_func(): + """Function {name} with {param}.""" + pass + + assert my_func.__doc__ is not None + assert "TestFunc" in my_func.__doc__ + assert "value" in my_func.__doc__ + assert "{name}" not in my_func.__doc__ + + def test_doc_format_no_docstring(self): + """Test doc_format with no docstring.""" + + @doc_format(name="Test") + def my_func(): + pass + + assert my_func.__doc__ is None + + def test_doc_format_after_add_example_raises(self): + """Test that doc_format after DocStringWithExample raises.""" + + def my_func(): + pass + + my_func.__doc__ = DocStringWithExample("Test {value}") + + with pytest.raises(ValueError, match="must be applied before @add_example"): + doc_format(value="test")(my_func) + + +class TestGetDecoratedSourceDirectory: + """Tests for get_decorated_source_directory function.""" + + def test_returns_directory_for_function(self): + """Test returns directory containing the function definition.""" + + def local_func(): + pass + + result = get_decorated_source_directory(local_func) + assert os.path.isdir(result) + # Should be in the shiny directory since we import from shiny + # (The function has __module__ pointing to this test file) diff --git a/tests/pytest/test_hostenv.py b/tests/pytest/test_hostenv.py new file mode 100644 index 000000000..325a37655 --- /dev/null +++ b/tests/pytest/test_hostenv.py @@ -0,0 +1,304 @@ +"""Tests for shiny/_hostenv.py - Host environment detection and URL proxying.""" + +import logging +import os +from unittest.mock import patch + +from shiny._hostenv import ( + ProxyUrlFilter, + get_proxy_url, + is_codespaces, + is_proxy_env, + is_workbench, + pat_local_url, + port_cache, +) + + +class TestIsWorkbench: + """Tests for is_workbench function.""" + + def test_is_workbench_false_by_default(self): + """Test returns False when env vars not set.""" + with patch.dict(os.environ, {}, clear=True): + assert is_workbench() is False + + def test_is_workbench_false_with_partial_env(self): + """Test returns False with only one env var set.""" + with patch.dict(os.environ, {"RS_SERVER_URL": "http://server"}, clear=True): + assert is_workbench() is False + + with patch.dict(os.environ, {"RS_SESSION_URL": "/session/"}, clear=True): + assert is_workbench() is False + + def test_is_workbench_true_with_both_env_vars(self): + """Test returns True when both env vars are set.""" + env = { + "RS_SERVER_URL": "http://server", + "RS_SESSION_URL": "/session/", + } + with patch.dict(os.environ, env, clear=True): + assert is_workbench() is True + + +class TestIsCodespaces: + """Tests for is_codespaces function.""" + + def test_is_codespaces_false_by_default(self): + """Test returns False when env vars not set.""" + with patch.dict(os.environ, {}, clear=True): + assert is_codespaces() is False + + def test_is_codespaces_false_with_partial_env(self): + """Test returns False with incomplete env vars.""" + with patch.dict(os.environ, {"CODESPACES": "true"}, clear=True): + assert is_codespaces() is False + + env = {"CODESPACES": "true", "CODESPACE_NAME": "myspace"} + with patch.dict(os.environ, env, clear=True): + assert is_codespaces() is False + + def test_is_codespaces_true_with_all_env_vars(self): + """Test returns True when all required env vars are set.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + assert is_codespaces() is True + + +class TestIsProxyEnv: + """Tests for is_proxy_env function.""" + + def test_is_proxy_env_false_by_default(self): + """Test returns False when not in proxy environment.""" + with patch.dict(os.environ, {}, clear=True): + assert is_proxy_env() is False + + def test_is_proxy_env_true_for_workbench(self): + """Test returns True when in Workbench.""" + env = { + "RS_SERVER_URL": "http://server", + "RS_SESSION_URL": "/session/", + } + with patch.dict(os.environ, env, clear=True): + assert is_proxy_env() is True + + def test_is_proxy_env_true_for_codespaces(self): + """Test returns True when in Codespaces.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + assert is_proxy_env() is True + + +class TestGetProxyUrl: + """Tests for get_proxy_url function.""" + + def test_returns_url_unchanged_when_not_proxy_env(self): + """Test URL unchanged when not in proxy environment.""" + with patch.dict(os.environ, {}, clear=True): + url = "http://localhost:8000/app" + assert get_proxy_url(url) == url + + def test_returns_url_unchanged_for_non_loopback(self): + """Test non-loopback URLs are returned unchanged.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://example.com:8000/app" + assert get_proxy_url(url) == url + + def test_returns_url_unchanged_for_port_0(self): + """Test port 0 URLs are returned unchanged.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://localhost:0/app" + assert get_proxy_url(url) == url + + def test_codespaces_proxy_url(self): + """Test URL proxying for Codespaces.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://localhost:8000/app" + result = get_proxy_url(url) + assert "myspace-8000" in result + assert "preview.app.github.dev" in result + assert "https://" in result + + def test_codespaces_proxy_url_websocket(self): + """Test WebSocket URL proxying for Codespaces.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "ws://localhost:8000/ws" + result = get_proxy_url(url) + assert "wss://" in result + assert "myspace-8000" in result + + def test_implicit_http_port(self): + """Test URL with implicit HTTP port 80.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://localhost/app" + result = get_proxy_url(url) + assert "myspace-80" in result + + def test_implicit_https_port(self): + """Test URL with implicit HTTPS port 443.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "https://localhost/app" + result = get_proxy_url(url) + assert "myspace-443" in result + + def test_127_0_0_1_is_loopback(self): + """Test that 127.0.0.1 is recognized as loopback.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + url = "http://127.0.0.1:8000/app" + result = get_proxy_url(url) + assert result != url + assert "myspace-8000" in result + + +class TestPatLocalUrl: + """Tests for the pat_local_url regex pattern.""" + + def test_matches_localhost(self): + """Test pattern matches localhost URLs.""" + assert pat_local_url.match("http://localhost:8000/path") + assert pat_local_url.match("https://localhost/") + + def test_matches_127_0_0_1(self): + """Test pattern matches 127.0.0.1 URLs.""" + assert pat_local_url.match("http://127.0.0.1:8000/path") + assert pat_local_url.match("https://127.0.0.1/") + + def test_case_insensitive(self): + """Test pattern is case insensitive.""" + assert pat_local_url.match("HTTP://LOCALHOST:8000/") + assert pat_local_url.match("http://LOCALHOST:8000/") + + def test_captures_host_and_port(self): + """Test pattern captures host and port.""" + match = pat_local_url.match("http://localhost:8000/my/path") + assert match is not None + # The regex captures the scheme, host, and optional port + full_match = match.group(0) + assert "localhost" in full_match + assert "8000" in full_match + + def test_search_finds_url_in_text(self): + """Test pattern can find URL within larger text.""" + text = "Visit http://localhost:8000 for the app" + match = pat_local_url.search(text) + assert match is not None + assert "localhost" in match.group(0) + + +class TestProxyUrlFilter: + """Tests for ProxyUrlFilter logging filter.""" + + def test_filter_creates_instance(self): + """Test ProxyUrlFilter can be instantiated.""" + filter_obj = ProxyUrlFilter() + assert filter_obj is not None + + def test_filter_returns_1(self): + """Test filter always returns 1 (allow log record).""" + filter_obj = ProxyUrlFilter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="http://localhost:8000/app", + args=(), + exc_info=None, + ) + result = filter_obj.filter(record) + assert result == 1 + + def test_filter_transforms_url_in_proxy_env(self): + """Test filter transforms URLs in proxy environment.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + filter_obj = ProxyUrlFilter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Visit http://localhost:8000/app", + args=(), + exc_info=None, + ) + filter_obj.filter(record) + assert "myspace-8000" in record.msg + assert "localhost" not in record.msg + + def test_filter_handles_color_message(self): + """Test filter handles color_message attribute.""" + env = { + "CODESPACES": "true", + "CODESPACE_NAME": "myspace", + "GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN": "preview.app.github.dev", + } + with patch.dict(os.environ, env, clear=True): + filter_obj = ProxyUrlFilter() + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname="", + lineno=0, + msg="Visit http://localhost:8000/app", + args=(), + exc_info=None, + ) + record.color_message = "Visit http://localhost:8000/app" # type: ignore + filter_obj.filter(record) + assert "myspace-8000" in str(record.color_message) # type: ignore + + +class TestPortCache: + """Tests for port_cache module variable.""" + + def test_port_cache_is_dict(self): + """Test port_cache is a dictionary.""" + assert isinstance(port_cache, dict) diff --git a/tests/pytest/test_html_dependencies.py b/tests/pytest/test_html_dependencies.py new file mode 100644 index 000000000..56b63567e --- /dev/null +++ b/tests/pytest/test_html_dependencies.py @@ -0,0 +1,118 @@ +"""Tests for shiny/html_dependencies.py - HTML dependency functions.""" + +import os +from unittest.mock import patch + +from htmltools import HTMLDependency + +from shiny import __version__ +from shiny.html_dependencies import jquery_deps, require_deps, shiny_deps + + +class TestShinyDeps: + """Tests for shiny_deps function.""" + + def test_returns_list(self): + """Test that shiny_deps returns a list.""" + deps = shiny_deps() + assert isinstance(deps, list) + + def test_contains_html_dependencies(self): + """Test that list contains HTMLDependency objects.""" + deps = shiny_deps() + for dep in deps: + assert isinstance(dep, HTMLDependency) + + def test_includes_shiny_dependency(self): + """Test that shiny dependency is included.""" + deps = shiny_deps() + dep_names = [dep.name for dep in deps] + assert "shiny" in dep_names + + def test_shiny_dependency_has_correct_version(self): + """Test that shiny dependency has correct version.""" + deps = shiny_deps() + shiny_dep = next(d for d in deps if d.name == "shiny") + assert str(shiny_dep.version) == __version__ + + def test_includes_css_by_default(self): + """Test that CSS is included by default.""" + deps = shiny_deps(include_css=True) + shiny_dep = next(d for d in deps if d.name == "shiny") + # Check that stylesheet is not None + assert shiny_dep.stylesheet is not None + + def test_excludes_css_when_false(self): + """Test that CSS can be excluded.""" + deps = shiny_deps(include_css=False) + shiny_dep = next(d for d in deps if d.name == "shiny") + # Check that stylesheet is empty or None + assert shiny_dep.stylesheet is None or len(shiny_dep.stylesheet) == 0 + + def test_includes_busy_indicators(self): + """Test that busy indicators dependency is included.""" + deps = shiny_deps() + # Should have at least 2 dependencies (shiny + busy_indicators) + assert len(deps) >= 2 + + def test_dev_mode_adds_devmode_dep(self): + """Test that dev mode adds shiny-devmode dependency.""" + with patch.dict(os.environ, {"SHINY_DEV_MODE": "1"}): + deps = shiny_deps() + dep_names = [dep.name for dep in deps] + assert "shiny-devmode" in dep_names + + def test_no_devmode_dep_when_not_dev_mode(self): + """Test that devmode dep not included when not in dev mode.""" + with patch.dict(os.environ, {"SHINY_DEV_MODE": "0"}, clear=False): + deps = shiny_deps() + dep_names = [dep.name for dep in deps] + assert "shiny-devmode" not in dep_names + + +class TestJqueryDeps: + """Tests for jquery_deps function.""" + + def test_returns_html_dependency(self): + """Test that jquery_deps returns HTMLDependency.""" + dep = jquery_deps() + assert isinstance(dep, HTMLDependency) + + def test_has_correct_name(self): + """Test that dependency has correct name.""" + dep = jquery_deps() + assert dep.name == "jquery" + + def test_has_version(self): + """Test that dependency has version.""" + dep = jquery_deps() + assert str(dep.version) == "3.6.0" + + def test_has_script(self): + """Test that dependency has script.""" + dep = jquery_deps() + assert dep.script is not None + + +class TestRequireDeps: + """Tests for require_deps function.""" + + def test_returns_html_dependency(self): + """Test that require_deps returns HTMLDependency.""" + dep = require_deps() + assert isinstance(dep, HTMLDependency) + + def test_has_correct_name(self): + """Test that dependency has correct name.""" + dep = require_deps() + assert dep.name == "requirejs" + + def test_has_version(self): + """Test that dependency has version.""" + dep = require_deps() + assert str(dep.version) == "2.3.6" + + def test_has_script(self): + """Test that dependency has script.""" + dep = require_deps() + assert dep.script is not None diff --git a/tests/pytest/test_include_helpers.py b/tests/pytest/test_include_helpers.py new file mode 100644 index 000000000..db5dd0c17 --- /dev/null +++ b/tests/pytest/test_include_helpers.py @@ -0,0 +1,274 @@ +"""Tests for shiny/ui/_include_helpers.py - Include helpers for JS/CSS.""" + +import os +import tempfile +from pathlib import Path + +import pytest + +from shiny.ui._include_helpers import ( + check_path, + get_file_key, + get_hash, + hash_deterministic, + include_css, + include_js, + read_utf8, +) + + +class TestIncludeJs: + """Tests for include_js function.""" + + def test_include_js_inline(self): + """Test include_js with inline method.""" + with tempfile.TemporaryDirectory() as tmpdir: + js_file = os.path.join(tmpdir, "test.js") + with open(js_file, "w") as f: + f.write('console.log("hello");') + + tag = include_js(js_file, method="inline") + html = str(tag) + assert " demo_{patch["value"]} - """) + """ + ) # @reactive.effect # def _(): diff --git a/tests/playwright/shiny/components/data_frame/patch_save_state/app.py b/tests/playwright/shiny/components/data_frame/patch_save_state/app.py index fb000dadc..a9870c02a 100644 --- a/tests/playwright/shiny/components/data_frame/patch_save_state/app.py +++ b/tests/playwright/shiny/components/data_frame/patch_save_state/app.py @@ -12,11 +12,13 @@ } ) -ui.markdown(""" +ui.markdown( + """ * Edit a cell in column `b`. * Column `a` should be updated to `a value` * The edited cell should be back to a `ready` state - """) + """ +) @render.data_frame diff --git a/tests/playwright/shiny/components/data_frame/styles_class/app.py b/tests/playwright/shiny/components/data_frame/styles_class/app.py index dfd77730b..62ed6a0c0 100644 --- a/tests/playwright/shiny/components/data_frame/styles_class/app.py +++ b/tests/playwright/shiny/components/data_frame/styles_class/app.py @@ -28,7 +28,8 @@ ] app_ui = ui.page_fillable( - ui.tags.style(""" + ui.tags.style( + """ .everywhere { color: darkorange !important; font-weight: bold; @@ -36,7 +37,8 @@ .species { background-color: lightblue !important; } - """), + """ + ), {"class": "p-3"}, ui.card( ui.card_header("Pandas Styles List:"), diff --git a/tests/playwright/shiny/components/nav/app.py b/tests/playwright/shiny/components/nav/app.py index fdfa5fab0..2fe5f59ad 100644 --- a/tests/playwright/shiny/components/nav/app.py +++ b/tests/playwright/shiny/components/nav/app.py @@ -94,11 +94,13 @@ def make_navset( fillable=False, footer=ui.div( {"style": "width:80%;margin: 0 auto"}, - ui.tags.style(""" + ui.tags.style( + """ h4 { margin-top: 3em; } - """), + """ + ), "page_navbar(): Footer (w/ custom styling)", make_navset( "navset_bar", ui.navset_bar, title=True, sidebar=True, headerfooter=True diff --git a/tests/playwright/shiny/session/clientdata/app.py b/tests/playwright/shiny/session/clientdata/app.py index 6f4fecd6b..2570574d9 100644 --- a/tests/playwright/shiny/session/clientdata/app.py +++ b/tests/playwright/shiny/session/clientdata/app.py @@ -7,12 +7,14 @@ with ui.sidebar(open="closed"): ui.input_slider("obs", "Number of observations:", min=0, max=1000, value=500) -ui.markdown(""" +ui.markdown( + """ #### `session.clientdata` values The following methods are available from the `session.clientdata` object and allow you to reactively read the client data values from the browser. -""") +""" +) @render.code diff --git a/tests/playwright/shiny/session/flush/app.py b/tests/playwright/shiny/session/flush/app.py index 155e5c9f7..39c046887 100644 --- a/tests/playwright/shiny/session/flush/app.py +++ b/tests/playwright/shiny/session/flush/app.py @@ -6,7 +6,8 @@ from shiny import App, Inputs, Outputs, Session, reactive, render, ui app_ui = ui.page_fluid( - ui.markdown(""" + ui.markdown( + """ # `session.on_flush` and `session.on_flushed` Reprex Verify that `on_flush` and `on_flushed` are called in the correct order, and that they can be cancelled, handle, synchronous functions, and handle asynchronous functions. @@ -27,7 +28,8 @@ Without something to continuously trigger the reactive graph, the `K` value will be `1` less than the click count. To combat this, a reactive event will trigger every 250ms to invoke session `flush` / `flushed` callback. ## Automated Reprex: - """), + """ + ), ui.input_action_button("btn", "Click me!"), ui.tags.br(), ui.tags.span("Counter: "), @@ -40,7 +42,8 @@ ui.output_text_verbatim("flushed_txt", placeholder=True), ui.tags.span("Session end events (refresh App to add events): "), ui.output_text_verbatim("session_end_txt", placeholder=True), - ui.tags.script(""" + ui.tags.script( + """ $(document).on('shiny:connected', function(event) { const n = 250 document.querySelector("#btn").click(); @@ -51,7 +54,8 @@ document.querySelector("#btn").click(); }, 2 * n) }); - """), + """ + ), ) session_ended_messages: List[str] = [] diff --git a/tests/pytest/test_markdown.py b/tests/pytest/test_markdown.py index ba0603846..a5d912c25 100644 --- a/tests/pytest/test_markdown.py +++ b/tests/pytest/test_markdown.py @@ -15,7 +15,8 @@ def test_markdown(): ) assert ( - markdown(""" + markdown( + """ # Hello World This is **markdown** and here is some `code`: @@ -23,20 +24,23 @@ def test_markdown(): ```python print('Hello world!') ``` - """) + """ + ) == HTML( "

Hello World

\n

This is markdown and here is some code:

\n
print('Hello world!')\n
\n" ) ) assert ( - markdown(""" + markdown( + """ # Hello World This is **markdown** and here is some `code`: print('Hello world!') - """) + """ + ) == HTML( "

Hello World

\n

This is markdown and here is some code:

\n
print('Hello world!')\n
\n" ) diff --git a/tests/pytest/test_navs.py b/tests/pytest/test_navs.py index 3cad38e1c..d017c0355 100644 --- a/tests/pytest/test_navs.py +++ b/tests/pytest/test_navs.py @@ -43,7 +43,8 @@ def test_navset_tab_markup(): with private_seed_n(): x = ui.navset_tab(a, b, ui.nav_control("Some item"), menu) - assert TagList(x).render()["html"] == textwrap.dedent("""\ + assert TagList(x).render()["html"] == textwrap.dedent( + """\