diff --git a/src/py/CHANGELOG.txt b/src/py/CHANGELOG.txt index f9b79007..5527f5d0 100644 --- a/src/py/CHANGELOG.txt +++ b/src/py/CHANGELOG.txt @@ -1,4 +1,13 @@ +- Add testing +- Fix a variety of type bugs +- Change order of browser closer to fix hang +- Explicitly handle certain argument options better +- Move temp file creation to .open() out of __init__() +- Reduce mathjax version to plotly.py +- Fix hang and add automatic close with stop_sync_server +- Add option to silence warnings in start/stop_sync_server - Fix bug where attribute was inconsistently named + v1.1.0rc0 - Improve verbosity of errors when starting kaleido improperly - Add new api functions start/stop_sync_server diff --git a/src/py/kaleido/_fig_tools.py b/src/py/kaleido/_fig_tools.py index fdcc63e5..30701808 100644 --- a/src/py/kaleido/_fig_tools.py +++ b/src/py/kaleido/_fig_tools.py @@ -1,15 +1,26 @@ +""" +Adapted from old code, it 1. validates, 2. write defaults, 3. packages object. + +Its a bit complicated and mixed in order. +""" + from __future__ import annotations import glob import re from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TypedDict +from typing import TYPE_CHECKING, Literal, TypedDict import logistro if TYPE_CHECKING: + from typing import Any + from typing_extensions import TypeGuard + Figurish = Any # Be nice to make it more specific, dictionary or something + FormatString = Literal["png", "jpg", "jpeg", "webp", "svg", "json", "pdf"] + _logger = logistro.getLogger(__name__) # constants @@ -17,20 +28,26 @@ DEFAULT_SCALE = 1 DEFAULT_WIDTH = 700 DEFAULT_HEIGHT = 500 -SUPPORTED_FORMATS = ("png", "jpg", "jpeg", "webp", "svg", "json", "pdf") -FormatString = Literal["png", "jpg", "jpeg", "webp", "svg", "json", "pdf"] +SUPPORTED_FORMATS: tuple[FormatString, ...] = ( + "png", + "jpg", + "jpeg", + "webp", + "svg", + "json", + "pdf", +) def _assert_format(ext: str) -> TypeGuard[FormatString]: if ext not in SUPPORTED_FORMATS: - raise ValueError(f"File format {ext} is not supported.") + raise ValueError( + f"Invalid format '{ext}'.\n Supported formats: {SUPPORTED_FORMATS!s}", + ) return True -Figurish = Any # Be nice to make it more specific, dictionary or something - - -def _is_figurish(o) -> TypeGuard[Figurish]: +def _is_figurish(o: Any) -> TypeGuard[Figurish]: valid = hasattr(o, "to_dict") or (isinstance(o, dict) and "data" in o) if not valid: _logger.debug( @@ -41,7 +58,11 @@ def _is_figurish(o) -> TypeGuard[Figurish]: return valid -def _get_figure_dimensions(layout, width, height): +def _get_figure_dimensions( + layout: dict, + width: float | None, + height: float | None, +) -> tuple[float, float]: # Compute image width / height with fallbacks width = ( width @@ -58,21 +79,16 @@ def _get_figure_dimensions(layout, width, height): return width, height -def _get_format(extension): - original_format = extension - extension = extension.lower() - if extension == "jpg": +def _get_format(extension: str) -> FormatString: + formatted_extension = extension.lower() + if formatted_extension == "jpg": return "jpeg" - - if extension not in SUPPORTED_FORMATS: - raise ValueError( - f"Invalid format '{original_format}'.\n" - f" Supported formats: {SUPPORTED_FORMATS!s}", - ) - return extension + if not _assert_format(formatted_extension): + raise ValueError # this line will never be reached its for typer + return formatted_extension -# Input of to_spec +# Input of to_spec (user gives us this) class LayoutOpts(TypedDict, total=False): format: FormatString | None scale: int | float @@ -80,7 +96,8 @@ class LayoutOpts(TypedDict, total=False): width: int | float -# Output of to_spec +# Output of to_spec (we give kaleido_scopes.js this) +# refactor note: this could easily be right before send class Spec(TypedDict): format: FormatString width: int | float @@ -89,7 +106,8 @@ class Spec(TypedDict): data: Figurish -def to_spec(figure, layout_opts: LayoutOpts) -> Spec: +# validate configuration options for kaleido.js and package like its wants +def to_spec(figure: Figurish, layout_opts: LayoutOpts) -> Spec: # Get figure layout layout = figure.get("layout", {}) @@ -107,6 +125,7 @@ def to_spec(figure, layout_opts: LayoutOpts) -> Spec: # Extract info extension = _get_format(layout_opts.get("format") or DEFAULT_EXT) + width, height = _get_figure_dimensions( layout, layout_opts.get("width"), @@ -123,7 +142,9 @@ def to_spec(figure, layout_opts: LayoutOpts) -> Spec: } -def _next_filename(path, prefix, ext) -> str: +# if we need to suffix the filename automatically: +def _next_filename(path: Path | str, prefix: str, ext: str) -> str: + path = path if isinstance(path, Path) else Path(path) default = 1 if (path / f"{prefix}.{ext}").exists() else 0 re_number = re.compile( r"^" + re.escape(prefix) + r"\-(\d+)\." + re.escape(ext) + r"$", @@ -139,36 +160,16 @@ def _next_filename(path, prefix, ext) -> str: return f"{prefix}.{ext}" if n == 1 else f"{prefix}-{n}.{ext}" -def build_fig_spec( # noqa: C901, PLR0912 +# validate and build full route if needed: +def _build_full_path( + path: Path | None, fig: Figurish, - path: Path | str | None, - opts: LayoutOpts | None, -) -> tuple[Spec, Path]: - if not opts: - opts = {} - - if not _is_figurish(fig): - raise TypeError("Figure supplied doesn't seem to be a valid plotly figure.") - - if hasattr(fig, "to_dict"): - fig = fig.to_dict() - - if isinstance(path, str): - path = Path(path) - elif path and not isinstance(path, Path): - raise TypeError("Path should be a string or `pathlib.Path` object (or None)") - - if path and path.suffix and not opts.get("format"): - ext = path.suffix.lstrip(".") - if _assert_format(ext): # not strict necessary if but helps typeguard - opts["format"] = ext - - spec = to_spec(fig, opts) - - ext = spec["format"] - + ext: FormatString, +) -> Path: full_path: Path | None = None + directory: Path + if not path: directory = Path() # use current Path elif path and (not path.suffix or path.is_dir()): @@ -181,6 +182,7 @@ def build_fig_spec( # noqa: C901, PLR0912 raise RuntimeError( f"Cannot reach path {path.parent}. Are all directories created?", ) + if not full_path: _logger.debug("Looking for title") prefix = fig.get("layout", {}).get("title", {}).get("text", "fig") @@ -190,5 +192,36 @@ def build_fig_spec( # noqa: C901, PLR0912 _logger.debug(f"Found: {prefix}") name = _next_filename(directory, prefix, ext) full_path = directory / name + return full_path + + +# call all validators/automatic config fill-in/packaging in expected format +def build_fig_spec( + fig: Figurish, + path: Path | str | None, + opts: LayoutOpts | None, +) -> tuple[Spec, Path]: + if not opts: + opts = {} + + if not _is_figurish(fig): + raise TypeError("Figure supplied doesn't seem to be a valid plotly figure.") + + if hasattr(fig, "to_dict"): + fig = fig.to_dict() + + if isinstance(path, str): + path = Path(path) + elif path and not isinstance(path, Path): + raise TypeError("Path should be a string or `pathlib.Path` object (or None)") + + if not opts.get("format") and path and path.suffix: + ext = path.suffix.lstrip(".") + if _assert_format(ext): # not strict necessary if but helps typeguard + opts["format"] = ext + + spec = to_spec(fig, opts) + + full_path = _build_full_path(path, fig, spec["format"]) return spec, full_path diff --git a/src/py/pyproject.toml b/src/py/pyproject.toml index 080be93f..d666a96a 100644 --- a/src/py/pyproject.toml +++ b/src/py/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "logistro>=1.0.8", "orjson>=3.10.15", "packaging", + "pytest-timeout>=2.4.0", ] [project.urls] @@ -94,7 +95,7 @@ log_cli = false # name = cmd [tool.poe.tasks.test] -cmd = "pytest --log-level=1 -W error -n auto -v -rfE --capture=fd" +cmd = "pytest --timeout=90 --log-level=1 -W error -n auto -v -rfE --capture=fd" help = "Run all tests quickly" [tool.poe.tasks.debug-test] diff --git a/src/py/tests/conftest.py b/src/py/tests/conftest.py index 8d4f32fd..f7e9f799 100644 --- a/src/py/tests/conftest.py +++ b/src/py/tests/conftest.py @@ -20,7 +20,7 @@ ) is_ci = os.getenv("GITHUB_ACTIONS") == "true" or os.getenv("CI") == "true" -if is_ci and platform.system in {"Windows", "Darwin"}: +if is_ci and platform.system() in {"Windows", "Darwin"}: settings.load_profile("ci") diff --git a/src/py/tests/test_fig_tools.py b/src/py/tests/test_fig_tools.py new file mode 100644 index 00000000..4fc8be11 --- /dev/null +++ b/src/py/tests/test_fig_tools.py @@ -0,0 +1,223 @@ +from pathlib import Path + +import pytest + +from kaleido import _fig_tools + +sources = ["argument", "layout", "template", "default"] +values = [None, 150, 800, 1500] + + +@pytest.mark.parametrize("width_source", sources) +@pytest.mark.parametrize("height_source", sources) +@pytest.mark.parametrize("width_value", values) +@pytest.mark.parametrize("height_value", [x * 1.5 if x else x for x in values]) +def test_get_figure_dimensions(width_source, height_source, width_value, height_value): + """Test _get_figure_dimensions with all combinations of width/height sources.""" + + layout = {} + width_arg = None + expected_width = width_value + + if width_source == "argument": + width_arg = width_value + elif width_source == "layout": + layout["width"] = width_value + elif width_source == "template": + layout.setdefault("template", {}).setdefault("layout", {})["width"] = ( + width_value + ) + else: # default + expected_width = None + + # Set to default if None + if expected_width is None: + expected_width = _fig_tools.DEFAULT_WIDTH + + # Do for height what I did for width + height_arg = None + expected_height = height_value + + if height_source == "argument": + height_arg = height_value + elif height_source == "layout": + layout["height"] = height_value + elif height_source == "template": + layout.setdefault("template", {}).setdefault("layout", {})["height"] = ( + height_value + ) + else: # default + expected_height = None + + # Set to default if None + if expected_height is None: + expected_height = _fig_tools.DEFAULT_HEIGHT + + # Call the function + r_width, r_height = _fig_tools._get_figure_dimensions( # noqa: SLF001 + layout, + width_arg, + height_arg, + ) + + # Assert results + assert r_width == expected_width, ( + f"Width mismatch: got {r_width}, expected {expected_width}, " + f"source: {width_source}, value: {width_value}" + ) + assert r_height == expected_height, ( + f"Height mismatch: got {r_height}, expected {expected_height}, " + f"source: {height_source}, value: {height_value}" + ) + + +def test_next_filename_no_existing_files(tmp_path): + """Test _next_filename when no files exist.""" + result = _fig_tools._next_filename(tmp_path, "test", "png") # noqa: SLF001 + assert result == "test.png" + + +def test_next_filename_base_file_exists(tmp_path): + """Test _next_filename when base file exists.""" + # Create the base file + (tmp_path / "test.png").touch() + + result = _fig_tools._next_filename(tmp_path, "test", "png") # noqa: SLF001 + assert result == "test-2.png" + + +def test_next_filename_numbered_files_exist(tmp_path): + """Test _next_filename when numbered files exist.""" + # Create various numbered files + (tmp_path / "test.png").touch() + (tmp_path / "test-2.png").touch() + (tmp_path / "test-3.png").touch() + (tmp_path / "test-5.png").touch() # Gap in numbering + + result = _fig_tools._next_filename(tmp_path, "test", "png") # noqa: SLF001 + assert result == "test-6.png" # Should be max + 1 + + +def test_next_filename_similar_names_ignored(tmp_path): + """Test _next_filename ignores files with similar but different names.""" + # Create files that shouldn't match the pattern + (tmp_path / "test.png").touch() + (tmp_path / "test-2.png").touch() + (tmp_path / "testing-3.png").touch() # Different prefix + (tmp_path / "test-2.jpg").touch() # Different extension + (tmp_path / "test-abc.png").touch() # Non-numeric suffix + + result = _fig_tools._next_filename(tmp_path, "test", "png") # noqa: SLF001 + assert result == "test-3.png" # Should only count test.png and test-2.png + + +def test_next_filename_special_characters(tmp_path): + """Test _next_filename with special characters in prefix and extension.""" + prefix = "test-file_name" + ext = "svg" # set up to be parameterized but not + + # Create some files + (tmp_path / f"{prefix}.{ext}").touch() + (tmp_path / f"{prefix}-2.{ext}").touch() + + result = _fig_tools._next_filename(tmp_path, prefix, ext) # noqa: SLF001 + assert result == f"{prefix}-3.{ext}" + + +def test_next_filename_only_numbered_files(tmp_path): + """Test _next_filename when only numbered files exist (no base file).""" + # Create only numbered files, no base file + (tmp_path / "test-2.png").touch() + (tmp_path / "test-3.png").touch() + (tmp_path / "test-10.png").touch() + + result = _fig_tools._next_filename(tmp_path, "test", "png") # noqa: SLF001 + assert result == "test-11.png" # Should be max + 1 + + +# Fixtures for _build_full_path tests - testing various title scenarios +@pytest.fixture( + params=[ + ( + { + "layout": { + "title": {"text": "My-Test!@#$%^&*()Chart_with[lots]of{symbols}"}, + }, + }, + "My_TestChart_withlotsofsymbols", + ), # Complex title + ( + {"layout": {"title": {"text": "Simple Title"}}}, + "Simple_Title", + ), # Simple title + ({"layout": {}}, "fig"), # No title + ], +) +def fig_fixture(request): + """Parameterized fixture for fig with various title scenarios.""" + return request.param + + +def test_build_full_path_no_path_input(fig_fixture): + """Test _build_full_path with no path input uses current path.""" + fig_dict, expected_prefix = fig_fixture + result = _fig_tools._build_full_path(None, fig_dict, "ext") # noqa: SLF001 + + # Should use current directory + assert result.parent.resolve() == Path().cwd().resolve() + assert result.parent.is_dir() + + assert result.name == f"{expected_prefix}.ext" + + +def test_build_full_path_no_suffix_directory(tmp_path, fig_fixture): + """Test _build_full_path with path having no suffix.""" + fig_dict, expected_prefix = fig_fixture + + # Test directory no suffix + test_dir = tmp_path + result = _fig_tools._build_full_path(test_dir, fig_dict, "ext") # noqa: SLF001 + + # Should use provided directory + assert result.parent == test_dir + assert result.name == f"{expected_prefix}.ext" + + # Test error + nonexistent_dir = Path("/nonexistent/directory") + with pytest.raises(ValueError, match=r"Directory .* not found. Please create it."): + _fig_tools._build_full_path(nonexistent_dir, fig_dict, "ext") # noqa: SLF001 + + +def test_build_full_path_directory_with_suffix(tmp_path, fig_fixture): + """Test _build_full_path with path that is directory even with suffix.""" + fig_dict, expected_prefix = fig_fixture + + # Create a directory with a suffix-like name + dir_with_suffix = tmp_path / "mydir.png" + dir_with_suffix.mkdir() + + result = _fig_tools._build_full_path(dir_with_suffix, fig_dict, "ext") # noqa: SLF001 + + # Should treat as directory + assert result.parent == dir_with_suffix + assert result.name == f"{expected_prefix}.ext" + + +def test_build_full_path_file_with_suffix(tmp_path, fig_fixture): + """Test _build_full_path with file path having suffix.""" + fig_dict, _expected_prefix = fig_fixture + + # Exists + file_path = tmp_path / "output.png" + result = _fig_tools._build_full_path(file_path, fig_dict, "ext") # noqa: SLF001 + + # Should return the exact path provided + assert result == file_path + + # Doesn't exist + file_path = Path("/nonexistent/directory/output.png") + with pytest.raises( + RuntimeError, + match=r"Cannot reach path .* Are all directories created?", + ): + _fig_tools._build_full_path(file_path, fig_dict, "ext") # noqa: SLF001 diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index 6c83dbab..6b178dce 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -203,7 +203,7 @@ async def test_write_fig_argument_passthrough( # noqa: PLR0913 mock_write_fig_from_object.assert_called_once() # Extract the generator that was passed as first argument - args, kwargs = mock_write_fig_from_object.call_args + args, _kwargs = mock_write_fig_from_object.call_args # not sure. assert len(args) == 1, "Expected exactly one argument (the generator)" generator = args[0] @@ -312,7 +312,7 @@ async def test_all_methods_non_context(simple_figure_with_bytes, tmp_path): await k.close() -@pytest.mark.parametrize("n_tabs", [1, 3, 7]) +@pytest.mark.parametrize("n_tabs", [1, 2, 3]) async def test_tab_count_verification(n_tabs): """Test that Kaleido creates the correct number of tabs.""" async with Kaleido(n=n_tabs) as k: diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index e213d6a9..e2b2e300 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -192,7 +192,7 @@ async def test_defaults_no_plotly_available(): # Test no imports (plotly not available) no_imports = PageGenerator().generate_index() - scripts, encodings = get_scripts_from_html(no_imports) + scripts, _encodings = get_scripts_from_html(no_imports) # Should have mathjax, plotly default, and kaleido_scopes assert len(scripts) == 3 # noqa: PLR2004 @@ -206,7 +206,7 @@ async def test_defaults_no_plotly_available(): async def test_defaults_with_plotly_available(): """Test defaults when plotly package is available.""" all_defaults = PageGenerator().generate_index() - scripts, encodings = get_scripts_from_html(all_defaults) + scripts, _encodings = get_scripts_from_html(all_defaults) # Should have mathjax, plotly package data, and kaleido_scopes assert len(scripts) == 3 # noqa: PLR2004 @@ -222,7 +222,7 @@ async def test_force_cdn(): pytest.skip("Plotly not available - cannot test force_cdn override") forced_cdn = PageGenerator(force_cdn=True).generate_index() - scripts, encodings = get_scripts_from_html(forced_cdn) + scripts, _encodings = get_scripts_from_html(forced_cdn) assert len(scripts) == 3 # noqa: PLR2004 assert scripts[0] == DEFAULT_MATHJAX @@ -234,7 +234,7 @@ async def test_force_cdn(): async def test_mathjax_false(): """Test that mathjax=False disables mathjax.""" without_mathjax = PageGenerator(mathjax=False).generate_index() - scripts, encodings = get_scripts_from_html(without_mathjax) + scripts, _encodings = get_scripts_from_html(without_mathjax) assert len(scripts) == 2 # noqa: PLR2004 assert scripts[0].endswith("package_data/plotly.min.js") @@ -367,7 +367,7 @@ async def test_existing_file_path(temp_js_file): # Test with regular path generator = PageGenerator(plotly=str(temp_js_file)) html = generator.generate_index() - scripts, encodings = get_scripts_from_html(html) + scripts, _encodings = get_scripts_from_html(html) assert len(scripts) == 3 # noqa: PLR2004 assert scripts[0] == DEFAULT_MATHJAX assert scripts[1] == str(temp_js_file) @@ -376,7 +376,7 @@ async def test_existing_file_path(temp_js_file): # Test with file:/// protocol generator_uri = PageGenerator(plotly=temp_js_file.as_uri()) html_uri = generator_uri.generate_index() - scripts_uri, encodings_uri = get_scripts_from_html(html_uri) + scripts_uri, _encodings_uri = get_scripts_from_html(html_uri) assert len(scripts_uri) == 3 # noqa: PLR2004 assert scripts_uri[0] == DEFAULT_MATHJAX assert scripts_uri[1] == temp_js_file.as_uri() @@ -444,7 +444,7 @@ async def test_http_urls_skip_file_validation(): others=["https://nonexistent.example.com/other.js"], ) html = generator.generate_index() - scripts, encodings = get_scripts_from_html(html) + scripts, _encodings = get_scripts_from_html(html) assert len(scripts) == 4 # noqa: PLR2004 assert scripts[0] == "https://nonexistent.example.com/mathjax.js" diff --git a/src/py/tests/test_sync_server.py b/src/py/tests/test_sync_server.py new file mode 100644 index 00000000..115c0281 --- /dev/null +++ b/src/py/tests/test_sync_server.py @@ -0,0 +1,93 @@ +import pytest + +from kaleido._sync_server import GlobalKaleidoServer + + +class TestGlobalKaleidoServer: + """Test the GlobalKaleidoServer singleton class.""" + + def test_singleton_behavior(self): + """Test that creating the object twice returns the same instance.""" + # Should be the same object + assert GlobalKaleidoServer() is GlobalKaleidoServer() + + def test_is_running_open_close_cycle(self): + """Test is_running, open, and close in a loop three times.""" + server = GlobalKaleidoServer() + + # Initial state should be not running + assert not server.is_running() + + for i in range(2): + # Check not running + assert not server.is_running(), ( + f"Iteration {i}: Should not be running initially" + ) + + server.open() + + # Check is running + assert server.is_running(), f"Iteration {i}: Should be running after open" + + # Call open again - should warn + with pytest.warns(RuntimeWarning, match="Server already open"): + server.open() + + server.open(silence_warnings=True) + server.close() + + # Call close again - should warn + with pytest.warns(RuntimeWarning, match="Server already closed"): + server.close() + + server.close(silence_warnings=True) + + # Check not running + assert not server.is_running(), ( + f"Iteration {i}: Should not be running after close" + ) + + def test_call_function_when_not_running_raises_error(self): + """Test that calling function when server is not running raises RuntimeError.""" + server = GlobalKaleidoServer() + + # Ensure server is closed + if server.is_running(): + server.close(silence_warnings=True) + + # Should raise RuntimeError + with pytest.raises(RuntimeError, match="Can't call function on stopped server"): + server.call_function("some_function") + + def test_getattr_call_function_integration(self): + """Test __getattr__ integration with call_function.""" + server = GlobalKaleidoServer() + method_name = "random_method" + test_args = ("arg1", "arg2") + test_kwargs = {"kwarg1": "value1"} + + def method_checker(_self, name): + assert name == method_name + + def dummy_method(*args, **kwargs): + assert args == test_args + assert kwargs == test_kwargs + + return dummy_method + + # Temporarily add __getattr__ to the class + GlobalKaleidoServer.__getattr__ = method_checker + + try: + # Call a random method with some args and kwargs + server.random_method(*test_args, **test_kwargs) + + finally: + # Clean up - remove __getattr__ from the class + delattr(GlobalKaleidoServer, "__getattr__") + + def teardown_method(self): + """Clean up after each test.""" + server = GlobalKaleidoServer() + if server.is_running(): + server.close(silence_warnings=True) diff --git a/src/py/uv.lock b/src/py/uv.lock index 9bc4dbda..9ecd010d 100644 --- a/src/py/uv.lock +++ b/src/py/uv.lock @@ -135,6 +135,7 @@ dependencies = [ { name = "orjson", version = "3.10.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "orjson", version = "3.11.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "packaging" }, + { name = "pytest-timeout" }, ] [package.dev-dependencies] @@ -166,6 +167,7 @@ requires-dist = [ { name = "logistro", specifier = ">=1.0.8" }, { name = "orjson", specifier = ">=3.10.15" }, { name = "packaging" }, + { name = "pytest-timeout", specifier = ">=2.4.0" }, ] [package.metadata.requires-dev] @@ -1084,6 +1086,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/73/59b038d1aafca89f8e9936eaa8ffa6bb6138d00459d13a32ce070be4f280/pytest_order-1.3.0-py3-none-any.whl", hash = "sha256:2cd562a21380345dd8d5774aa5fd38b7849b6ee7397ca5f6999bbe6e89f07f6e", size = 14609, upload-time = "2024-08-22T12:29:53.156Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "pytest-xdist" version = "3.6.1"