From 7c098d080151f1263865de60b00111ff0d054252 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 20 Aug 2025 11:43:47 -0500 Subject: [PATCH 001/167] Create async iter wrapper to simplify loops --- src/py/kaleido/_utils.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/py/kaleido/_utils.py b/src/py/kaleido/_utils.py index 052f79b9..1c7c24cb 100644 --- a/src/py/kaleido/_utils.py +++ b/src/py/kaleido/_utils.py @@ -10,6 +10,25 @@ _logger = logistro.getLogger(__name__) +def ensure_async_iter(obj): + if hasattr(obj, "__aiter__"): + return obj + + it = iter(obj) + + class _AIter: + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(it) + except StopIteration: + raise StopAsyncIteration # noqa: B904 + + return _AIter() + + async def to_thread(func, *args, **kwargs): _loop = asyncio.get_running_loop() fn = partial(func, *args, **kwargs) From 3c94ef045a7f869dc8aa10f68ae3ad25d0aa5e2a Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 20 Aug 2025 11:49:39 -0500 Subject: [PATCH 002/167] Simplify loop w/ new wrapper. --- src/py/kaleido/kaleido.py | 78 ++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 42 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index a79cbec5..74fa8c42 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -16,7 +16,7 @@ from ._fig_tools import _is_figurish, build_fig_spec from ._kaleido_tab import _KaleidoTab from ._page_generator import PageGenerator -from ._utils import ErrorEntry, warn_incompatible_plotly +from ._utils import ErrorEntry, ensure_async_iter, warn_incompatible_plotly _logger = logistro.getLogger(__name__) @@ -347,7 +347,7 @@ async def calc_fig( await self._return_kaleido_tab(tab) return data[0] - async def write_fig( # noqa: PLR0913, C901 (too many args, complexity) + async def write_fig( # noqa: PLR0913 (too many args) self, fig, path=None, @@ -440,7 +440,7 @@ async def _loop(f): finally: self._main_tasks.remove(main_task) - async def write_fig_from_object( # noqa: C901 too complex + async def write_fig_from_object( self, generator, *, @@ -487,48 +487,42 @@ async def write_fig_from_object( # noqa: C901 too complex self._main_tasks.add(main_task) tasks = set() - async def _loop(args): - spec, full_path = build_fig_spec( - args.pop("fig"), - args.pop("path", None), - args.pop("opts", None), - ) - args["spec"] = spec - args["full_path"] = full_path - tab = await self._get_kaleido_tab() - if profiler is not None and tab.tab.target_id not in profiler: - profiler[tab.tab.target_id] = [] - t = asyncio.create_task( - self._render_task( - tab, - args=args, - error_log=error_log, - profiler=profiler, - ), - ) - t.add_done_callback( - partial( - self._check_render_task, - full_path.name, - tab, - main_task, - error_log, - ), - ) - tasks.add(t) - try: - if hasattr(generator, "__aiter__"): # is async iterable - _logger.debug("Is async for") - async for args in generator: - await _loop(args) - else: - _logger.debug("Is sync for") - for args in generator: - await _loop(args) - _logger.debug("awaiting tasks") + async for args in ensure_async_iter(generator): + args["spec"], args["full_path"] = build_fig_spec( + args.pop("fig"), + args.pop("path", None), + args.pop("opts", None), + ) + + tab = await self._get_kaleido_tab() + + if profiler is not None and tab.tab.target_id not in profiler: + profiler[tab.tab.target_id] = [] + + t = asyncio.create_task( + self._render_task( + tab, + args=args, + error_log=error_log, + profiler=profiler, + ), + ) + t.add_done_callback( + partial( + self._check_render_task, + args["full_path"].name, + tab, + main_task, + error_log, + ), + ) + tasks.add(t) + await asyncio.gather(*tasks, return_exceptions=True) + # This here would include errors during the tasks? except: + # This would only catch errors in the machinery, not the task _logger.exception("Cleaning tasks after error.") for task in tasks: if not task.done(): From 056206aef0dcdc23f581a797431b4703b9d7fdf2 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 20 Aug 2025 21:36:15 -0500 Subject: [PATCH 003/167] Refactor kaleido.py: Refactors often touch a lot of parts, and this is a pretty big refactor. We reduce code deplication, improve errors and logic, and simply task management. --- src/py/kaleido/_page_generator.py | 16 +- src/py/kaleido/_utils.py | 54 ++- src/py/kaleido/kaleido.py | 543 ++++++++---------------------- 3 files changed, 171 insertions(+), 442 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index cda04aff..beb6bd50 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -108,14 +108,8 @@ def __init__(self, *, plotly=None, mathjax=None, others=None, force_cdn=False): _ensure_path(o) self._scripts.extend(others) - def generate_index(self, path=None): - """ - Generate the page. - - Args: - path: If specified, page is written to path. Otherwise it is returned. - - """ + def generate_index(self): + """Generate the page.""" page = self.header script_tag = '\n ' script_tag_charset = '\n ' @@ -126,8 +120,4 @@ def generate_index(self, path=None): page += script_tag_charset % script page += self.footer _logger.debug2(page) - if not path: - return page - with (path).open("w") as f: - f.write(page) - return path.as_uri() + return page diff --git a/src/py/kaleido/_utils.py b/src/py/kaleido/_utils.py index 1c7c24cb..ba886a36 100644 --- a/src/py/kaleido/_utils.py +++ b/src/py/kaleido/_utils.py @@ -1,5 +1,4 @@ import asyncio -import traceback import warnings from functools import partial from importlib.metadata import PackageNotFoundError, version @@ -10,7 +9,32 @@ _logger = logistro.getLogger(__name__) +def event_printer(name): + """Return function that prints whatever argument received.""" + + async def print_all(response): + _logger.debug2(f"{name!s}:{response!s}") + + return print_all + + +def _clean_error(t: asyncio.Task) -> None: + """Check a task to avoid "task never awaited" errors.""" + if t.cancelled(): + _logger.error(f"{t} cancelled.") + elif (exc := t.exception()) is not None: + _logger.error(f"{t} raised error.", exc_info=exc) + + +def create_task_log_error(coroutine) -> asyncio.Task: + """Create a task and assign a callback to log its errors.""" + t = asyncio.create_task(coroutine) + t.add_done_callback(_clean_error) + return t + + def ensure_async_iter(obj): + """Convert any iterable to an async iterator.""" if hasattr(obj, "__aiter__"): return obj @@ -30,6 +54,7 @@ async def __anext__(self): async def to_thread(func, *args, **kwargs): + """Polyfill `asyncio.to_thread()`.""" _loop = asyncio.get_running_loop() fn = partial(func, *args, **kwargs) await _loop.run_in_executor(None, fn) @@ -68,30 +93,3 @@ def warn_incompatible_plotly(): # Since this compatibility check is just a convenience, # we don't want to block the whole library if there's an issue _logger.info("Error while checking Plotly version.", exc_info=e) - - -class ErrorEntry: - """A simple object to record errors and context.""" - - def __init__(self, name, error, javascript_log): - """ - Construct an error entry. - - Args: - name: the name of the image with the error - error: the error object (from class BaseException) - javascript_log: an array of entries from the javascript console - - """ - self.name = name - self.error = error - self.javascript_log = javascript_log - - def __str__(self): - """Display the error object in a concise way.""" - ret = f"{self.name}:\n" - e = self.error - ret += " ".join(traceback.format_exception(type(e), e, e.__traceback__)) - ret += " javascript Log:\n" - ret += "\n ".join(self.javascript_log) - return ret diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 74fa8c42..446c8d8f 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -3,124 +3,57 @@ from __future__ import annotations import asyncio -import warnings from collections.abc import Iterable -from functools import partial from pathlib import Path +from urllib.parse import urlparse import choreographer as choreo import logistro from choreographer.errors import ChromeNotFoundError from choreographer.utils import TmpDirectory +from . import _utils from ._fig_tools import _is_figurish, build_fig_spec from ._kaleido_tab import _KaleidoTab from ._page_generator import PageGenerator -from ._utils import ErrorEntry, ensure_async_iter, warn_incompatible_plotly _logger = logistro.getLogger(__name__) # Show a warning if the installed Plotly version # is incompatible with this version of Kaleido -warn_incompatible_plotly() - - -def _make_printer(name): - """Create event printer for generic events. Helper function.""" - - async def print_all(response): - _logger.debug2(f"{name}:{response}") - - return print_all +_utils.warn_incompatible_plotly() class Kaleido(choreo.Browser): - """ - Kaleido manages a set of image processors. - - It can be used as a context (`async with Kaleido(...)`), but can - also be used like: - - ``` - k = Kaleido(...) - k = await Kaleido.open() - ... # do stuff - k.close() - ``` - """ - _tabs_ready: asyncio.Queue[_KaleidoTab] - _background_render_tasks: set[asyncio.Task] - # not really render tasks - _main_tasks: set[asyncio.Task] + _tab_requeue_tasks: set[asyncio.Task] + _main_render_coroutines: set[asyncio.Task] + # technically Tasks, user sees coroutines - async def close(self): - """Close the browser.""" - if self._tmp_dir: - self._tmp_dir.clean() - _logger.info("Cancelling tasks.") - for task in self._main_tasks: - if not task.done(): - task.cancel() - for task in self._background_render_tasks: - if not task.done(): - task.cancel() - _logger.info("Exiting Kaleido/Choreo") - return await super().close() + _total_tabs: int + _html_tmp_dir: None | TmpDirectory - async def __aexit__(self, exc_type, exc_value, exc_tb): - """Close the browser.""" - _logger.info("Waiting for all cleanups to finish.") - await asyncio.gather(*self._background_render_tasks, return_exceptions=True) - _logger.info("Exiting Kaleido") - return await super().__aexit__(exc_type, exc_value, exc_tb) - - def __init__(self, *args, **kwargs): # noqa: D417 no args/kwargs in description - """ - Initialize Kaleido, a `choreo.Browser` wrapper adding kaleido functionality. - - It takes all `choreo.Browser` args, plus some extra. The extra - are listed, see choreographer for more documentation. - - Note: Chrome will throttle background tabs and windows, so non-headless - multi-process configurations don't work well. + ### KALEIDO LIFECYCLE FUNCTIONS ### - For argument `page`, if it is a string, it must be passed as a fully-qualified - URI, like `file://` or `https://`. - If it is a `Path`, `Path`'s `as_uri()` will be called. - If it is a string or path, its expected to be an HTML file, one will not - be generated. - - Args: - n: the number of separate processes (windows, not seen) to use. - timeout: limit on any single render (default 90 seconds). - width: width of window (headless only) - height: height of window (headless only) - page: This can be a `kaleido.PageGenerator`, a `pathlib.Path`, or a string. - - """ - self._background_render_tasks = set() - self._main_tasks = set() + def __init__(self, *args, **kwargs): + self._tab_requeue_tasks = set() + self._main_render_coroutines = set() self._tabs_ready = asyncio.Queue(maxsize=0) self._total_tabs = 0 - self._tmp_dir = None + self._html_tmp_dir = None + # Kaleido Config page = kwargs.pop("page_generator", None) self._timeout = kwargs.pop("timeout", 90) self._n = kwargs.pop("n", 1) - self._height = kwargs.pop("height", None) - self._width = kwargs.pop("width", None) - self._stepper = kwargs.pop("stepper", False) + if self._n <= 0: + raise ValueError("Argument `n` must be greater than 0") self._plotlyjs = kwargs.pop("plotlyjs", None) self._mathjax = kwargs.pop("mathjax", None) - if not kwargs.get("headless", True) and (self._height or self._width): - warnings.warn( - "Height and Width can only be used if headless=True, " - "ignoring both sizes.", - stacklevel=1, - ) - self._height = None - self._width = None + + # Diagnostic + self._stepper = kwargs.pop("stepper", False) + _logger.debug(f"Timeout: {self._timeout}") try: @@ -133,41 +66,45 @@ def __init__(self, *args, **kwargs): # noqa: D417 no args/kwargs in description "or `kaleido.get_chrome_sync()`.", ) from ChromeNotFoundError + # not a lot of error detection here if page and isinstance(page, str) and Path(page).is_file(): + self._index = Path(page).as_uri() + elif page and isinstance(page, str) and page.startswith("file://"): self._index = page elif page and hasattr(page, "is_file") and page.is_file(): self._index = page.as_uri() + elif page and not hasattr(page, "generate_index"): + raise ValueError( + "`page_generator argument` must be None, an existing file or " + "an object with a generate_index function such as a " + "kaleido.PageGenerator().", + ) else: - self._tmp_dir = TmpDirectory(sneak=self.is_isolated()) - index = self._tmp_dir.path / "index.html" + self._html_tmp_dir = TmpDirectory(sneak=self.is_isolated()) + index = self._html_tmp_dir.path / "index.html" self._index = index.as_uri() if not page: page = PageGenerator(plotly=self._plotlyjs, mathjax=self._mathjax) - page.generate_index(index) + with index.open("w") as f: + f.write(page.generate_index()) + + if not Path(urlparse(self._index).path).is_file(): + raise FileNotFoundError(f"{page} not found.") - async def _conform_tabs(self, tabs=None) -> None: - if not tabs: - tabs = list(self.tabs.values()) + async def _conform_tabs(self, tabs) -> None: _logger.info(f"Conforming {len(tabs)} to {self._index}") for i, tab in enumerate(tabs): - n = f"tab-{i!s}" _logger.debug2(f"Subscribing * to tab: {tab}.") - tab.subscribe("*", _make_printer(n + " event")) - - _logger.debug("Navigating all tabs") + tab.subscribe("*", _utils.make_printer(f"tab-{i!s} event")) kaleido_tabs = [_KaleidoTab(tab, _stepper=self._stepper) for tab in tabs] - # A little hard to read because we don't have TaskGroup in this version - tasks = [asyncio.create_task(tab.navigate(self._index)) for tab in kaleido_tabs] - _logger.info("Waiting on all navigates") - await asyncio.gather(*tasks) - _logger.info("All navigates done, putting them all in queue.") + await asyncio.gather(*(tab.navigate(self._index) for tab in kaleido_tabs)) + for tab in kaleido_tabs: + self._total_tabs += 1 await self._tabs_ready.put(tab) - self._total_tabs = len(kaleido_tabs) - _logger.debug("Tabs fully navigated/enabled/ready") async def populate_targets(self) -> None: """ @@ -177,46 +114,52 @@ async def populate_targets(self) -> None: once ever per object. """ await super().populate_targets() - await self._conform_tabs() + await self._conform_tabs(self.tabs.values()) needed_tabs = self._n - len(self.tabs) - if needed_tabs < 0: - raise RuntimeError("Did you set 0 or less tabs?") if not needed_tabs: return - tasks = [ - asyncio.create_task(self._create_kaleido_tab()) for _ in range(needed_tabs) - ] - await asyncio.gather(*tasks) - for tab in self.tabs.values(): - _logger.info(f"Tab ready: {tab.target_id}") + await asyncio.gather( + *(self._create_kaleido_tab() for _ in range(needed_tabs)), + ) - async def _create_kaleido_tab( - self, - ) -> None: - """ - Create a tab with the kaleido script. + async def close(self): + """Close the browser.""" + if self._html_tmp_dir: + self._html_tmp_dir.clean() + + # cancellation only happens if crash/early + _logger.info("Cancelling tasks.") + for task in self._main_render_coroutines: + if not task.done(): + task.cancel() + for task in self._tab_requeue_tasks: + if not task.done(): + task.cancel() - Returns: - The kaleido-tab created. + _logger.info("Exiting Kaleido/Choreo.") + return await super().close() - """ + async def __aexit__(self, exc_type, exc_value, exc_tb): + """Close the browser.""" + _logger.info("Waiting for all cleanups to finish.") + + # render "tasks" are coroutines, so use is awaiting them + await asyncio.gather(*self._tab_requeue_tasks, return_exceptions=True) + + _logger.info("Exiting Kaleido.") + return await super().__aexit__(exc_type, exc_value, exc_tb) + + ### TAB MANAGEMENT FUNCTIONS #### + + async def _create_kaleido_tab(self) -> None: tab = await super().create_tab( url="", - width=self._width, - height=self._height, window=True, ) await self._conform_tabs([tab]) async def _get_kaleido_tab(self) -> _KaleidoTab: - """ - Retrieve an available tab from queue. - - Returns: - A kaleido-tab from the queue. - - """ _logger.info(f"Getting tab from queue (has {self._tabs_ready.qsize()})") if not self._total_tabs: raise RuntimeError( @@ -226,14 +169,7 @@ async def _get_kaleido_tab(self) -> _KaleidoTab: _logger.info(f"Got {tab.tab.target_id[:4]}") return tab - async def _return_kaleido_tab(self, tab): - """ - Refresh tab and put it back into the available queue. - - Args: - tab: the kaleido tab to return. - - """ + async def _return_kaleido_tab(self, tab) -> None: _logger.info(f"Reloading tab {tab.tab.target_id[:4]} before return.") await tab.reload() _logger.info( @@ -243,290 +179,95 @@ async def _return_kaleido_tab(self, tab): await self._tabs_ready.put(tab) _logger.debug(f"{tab.tab.target_id[:4]} put back.") - def _clean_tab_return_task(self, main_task, task): - _logger.info("Cleaning out background tasks.") - self._background_render_tasks.remove(task) - e = task.exception() - if e: - _logger.error("Clean tab return task found exception", exc_info=e) - if not main_task.done(): - main_task.cancel() - raise e - - def _check_render_task(self, name, tab, main_task, error_log, task): - if task.cancelled(): - _logger.info(f"Something cancelled {name}.") - error_log.append( - ErrorEntry(name, asyncio.CancelledError, tab.javascript_log), - ) - elif e := task.exception(): - _logger.error(f"Render Task Error In {name}- ", exc_info=e) - if isinstance(e, (asyncio.TimeoutError, TimeoutError)) and error_log: - error_log.append( - ErrorEntry(name, e, tab.javascript_log), - ) - else: - _logger.error("Cancelling all.") - if not main_task.done(): - main_task.cancel() - raise e - _logger.info(f"Returning {name} tab after render.") - t = asyncio.create_task(self._return_kaleido_tab(tab)) - self._background_render_tasks.add(t) - t.add_done_callback(partial(self._clean_tab_return_task, main_task)) - - async def _render_task(self, tab, args, error_log=None, profiler=None): + async def _create_render_task(self, tab, *args, **kwargs) -> asyncio.Task: _logger.info(f"Posting a task for {args['full_path'].name}") - if self._timeout: - try: - await asyncio.wait_for( - tab._write_fig( # noqa: SLF001 I don't want it documented, too complex for user - **args, - error_log=error_log, - profiler=profiler, - ), - self._timeout, # timeout can be None, no need for branches - ) - except BaseException as e: - if error_log: - error_log.append( - ErrorEntry( - args["full_path"].name, - e, - tab.javascript_log - if hasattr( - tab, - "javascript_log", - ) - else [], - ), - ) - else: - raise - - else: - await tab._write_fig( # noqa: SLF001 I don't want it documented, too complex for user - **args, - error_log=error_log, - profiler=profiler, - ) - _logger.info(f"Posted task ending for {args['full_path'].name}") - - async def calc_fig( - self, - fig, - path=None, - opts=None, - *, - topojson=None, - ): - """ - Calculate the bytes for a figure. - - This function does not support parallelism or multi-image processing like - `write_fig` does, although its arguments are a subset of those of `write_fig`. - This function is currently just meant to bridge the old and new API. - """ - if not _is_figurish(fig) and isinstance(fig, Iterable): - raise TypeError("Calc fig can not process multiple images at a time.") - spec, full_path = build_fig_spec(fig, path, opts) - tab = await self._get_kaleido_tab() - args = { - "spec": spec, - "full_path": full_path, - "topojson": topojson, - } - data = None - timeout = self._timeout if self._timeout else None - data = await asyncio.wait_for( - tab._calc_fig( # noqa: SLF001 I don't want it documented, too complex for user - **args, + t = asyncio.create_task( + asyncio.wait_for( + tab._write_fig( # noqa: SLF001 + *args, + **kwargs, + ), + self._timeout, ), - timeout, ) - await self._return_kaleido_tab(tab) - return data[0] - async def write_fig( # noqa: PLR0913 (too many args) - self, - fig, - path=None, - opts=None, - *, - topojson=None, - error_log=None, - profiler=None, - ): - """ - Call the plotly renderer via javascript on first available tab. - - Args: - fig: the plotly figure or an iterable of plotly figures - path: the path to write the images to. if its a directory, we will try to - generate a name. If the path contains an extension, - "path/to/my_image.png", that extension will be the format used if not - overridden in `opts`. If you pass a complete path (filename), for - multiple figures, you will overwrite every previous figure. - opts: dictionary describing format, width, height, and scale of image - topojson: a link ??? TODO - error_log: a supplied list, will be populated with `ErrorEntry`s - which can be converted to strings. Note, this is for - collections errors that have to do with plotly. They will - not be thrown. Lower level errors (kaleido, choreographer) - will still be thrown. If not passed, all errors raise. - profiler: a supplied dictionary to collect stats about the operation - about tabs, runtimes, etc. - - """ - if error_log is not None: - _logger.info("Using error log.") - if profiler is not None: - _logger.info("Using profiler.") - - if _is_figurish(fig) or not isinstance(fig, Iterable): - fig = [fig] - else: - _logger.debug(f"Is iterable {type(fig)}") - - main_task = asyncio.current_task() - self._main_tasks.add(main_task) - tasks = set() - - async def _loop(f): - spec, full_path = build_fig_spec(f, path, opts) - tab = await self._get_kaleido_tab() - if profiler is not None and tab.tab.target_id not in profiler: - profiler[tab.tab.target_id] = [] - t = asyncio.create_task( - self._render_task( - tab, - args={ - "spec": spec, - "full_path": full_path, - "topojson": topojson, - }, - error_log=error_log, - profiler=profiler, - ), - ) - t.add_done_callback( - partial( - self._check_render_task, - full_path.name, - tab, - main_task, - error_log, + # we just log any error but honestly it would be pretty fatal + t.add_done_callback( + lambda: self._tab_requeue_tasks.add( + _utils.create_task_log_error( + self._return_kaleido_tab(tab), ), - ) - tasks.add(t) + ), + ) + _logger.info(f"Posted task ending for {args['full_path'].name}") + return t - try: - if hasattr(fig, "__aiter__"): # is async iterable - _logger.debug("Is async for") - async for f in fig: - await _loop(f) - else: - _logger.debug("Is sync for") - for f in fig: - await _loop(f) - _logger.debug("awaiting tasks") - await asyncio.gather(*tasks, return_exceptions=True) - except: - _logger.exception("Cleaning tasks after error.") - for task in tasks: - if not task.done(): - task.cancel() - raise - finally: - self._main_tasks.remove(main_task) + ### API ### + # also write_fig_from_dict async def write_fig_from_object( self, - generator, + generator, # what should we accept here *, - error_log=None, - profiler=None, - ): - """ - Equal to `write_fig` but allows the user to generate all arguments. - - Generator must yield dictionaries with keys: - - fig: the plotly figure - - path: (optional, string or pathlib.Path) the path - - opts: (optional) dictionary with: - - format (string) - - scale (number) - - height (number) - - and width (number) - - topojson: (optional) topojsons are used to customize choropleths - - Generators are good because, if rendering many images, one doesn't need to - prerender them all. They can be rendered and yielded asynchronously. - - While `write_fig` can also take generators, but only for the figure. - In this case, the generator will specify all render-related arguments. - - Args: - generator: an iterable or generator which supplies a dictionary - of arguments to pass to tab.write_fig. - error_log: A supplied list, will be populated with `ErrorEntry`s - which can be converted to strings. Note, this is for - collections errors that have to do with plotly. They will - not be thrown. Lower level errors (kaleido, choreographer) - will still be thrown. - profiler: A supplied dictionary, will be populated with information - about tabs, runtimes, etc. - - """ - if error_log is not None: - _logger.info("Using error log.") - if profiler is not None: - _logger.info("Using profiler.") - + cancel_on_error=False, + ) -> None: + """Temp.""" main_task = asyncio.current_task() - self._main_tasks.add(main_task) + self._main_render_coroutines.add(main_task) tasks = set() try: - async for args in ensure_async_iter(generator): - args["spec"], args["full_path"] = build_fig_spec( - args.pop("fig"), - args.pop("path", None), - args.pop("opts", None), + async for args in _utils.ensure_async_iter(generator): + spec, full_path = build_fig_spec( + args.get("fig"), + args.get("path", None), + args.get("opts", None), ) + topojson = args.get("topojson") tab = await self._get_kaleido_tab() - if profiler is not None and tab.tab.target_id not in profiler: - profiler[tab.tab.target_id] = [] - - t = asyncio.create_task( - self._render_task( - tab, - args=args, - error_log=error_log, - profiler=profiler, - ), - ) - t.add_done_callback( - partial( - self._check_render_task, - args["full_path"].name, - tab, - main_task, - error_log, - ), + t = self._create_render_task( + tab, + spec, + full_path, + topojson=topojson, ) + tasks.add(t) - await asyncio.gather(*tasks, return_exceptions=True) - # This here would include errors during the tasks? - except: - # This would only catch errors in the machinery, not the task - _logger.exception("Cleaning tasks after error.") + await asyncio.gather(*tasks, return_exceptions=not cancel_on_error) + + finally: for task in tasks: if not task.done(): task.cancel() - raise - finally: - self._main_tasks.remove(main_task) + self._main_render_coroutines.remove(main_task) + # return errors? + + async def write_fig( + self, + fig, + path=None, + opts=None, + *, + topojson=None, + ) -> None: + """Temp.""" + if _is_figurish(fig) or not isinstance(fig, Iterable): + fig = [fig] + else: + _logger.debug(f"Is iterable {type(fig)}") + + async def _temp_generator(): + async for f in _utils.ensure_async_iter(fig): + yield { + "fig": f, + "path": path, + "opts": opts, + "topojson": topojson, + } + + return await self.write_fig_from_object( + generator=_temp_generator(), + ) From 8725a80b9a42866c328bdac70a403506f1bdb333 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 21 Aug 2025 12:30:57 -0500 Subject: [PATCH 004/167] Change order of functions. --- src/py/kaleido/_kaleido_tab.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/py/kaleido/_kaleido_tab.py b/src/py/kaleido/_kaleido_tab.py index 6288fa06..7af72181 100644 --- a/src/py/kaleido/_kaleido_tab.py +++ b/src/py/kaleido/_kaleido_tab.py @@ -47,13 +47,7 @@ def __str__(self): return f"Error {self._code}: {self._message}" -def _check_error(result): - e = _check_error_ret(result) - if e: - raise e - - -def _check_error_ret(result): # Utility +def _check_error_ret(result): """Check browser response for errors. Helper function.""" if "error" in result: return DevtoolsProtocolError(result) @@ -62,6 +56,12 @@ def _check_error_ret(result): # Utility return None +def _check_error(result): + e = _check_error_ret(result) + if e: + raise e + + def _make_console_logger(name, log): """Create printer specifically for console events. Helper function.""" @@ -85,6 +85,14 @@ class _KaleidoTab: javascript_log: list[Any] """A list of console outputs from the tab.""" + def _regenerate_javascript_console(self): + self.javascript_log = [] + self.tab.unsubscribe("Runtime.consoleAPICalled") + self.tab.subscribe( + "Runtime.consoleAPICalled", + _make_console_logger("tab js console", self.javascript_log), + ) + def __init__(self, tab, *, _stepper=False): """ Create a new _KaleidoTab. @@ -97,16 +105,6 @@ def __init__(self, tab, *, _stepper=False): self.javascript_log = [] self._stepper = _stepper - def _regenerate_javascript_console(self): - tab = self.tab - self.javascript_log = [] - _logger.debug2("Subscribing to all console prints for tab {tab}.") - tab.unsubscribe("Runtime.consoleAPICalled") - tab.subscribe( - "Runtime.consoleAPICalled", - _make_console_logger("tab js console", self.javascript_log), - ) - async def navigate(self, url: str | Path = ""): """ Navigate to the kaleidofier script. This is effectively the real initialization. From 41133f2cde83fef0f066dc82b45ba04261b72363 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 21 Aug 2025 13:57:04 -0500 Subject: [PATCH 005/167] Make tabs_ready public --- src/py/kaleido/kaleido.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index a79cbec5..dbfb5d4e 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -49,7 +49,8 @@ class Kaleido(choreo.Browser): ``` """ - _tabs_ready: asyncio.Queue[_KaleidoTab] + tabs_ready: asyncio.Queue[_KaleidoTab] + """A queue of ready tabs.""" _background_render_tasks: set[asyncio.Task] # not really render tasks _main_tasks: set[asyncio.Task] @@ -101,7 +102,7 @@ def __init__(self, *args, **kwargs): # noqa: D417 no args/kwargs in description """ self._background_render_tasks = set() self._main_tasks = set() - self._tabs_ready = asyncio.Queue(maxsize=0) + self.tabs_ready = asyncio.Queue(maxsize=0) self._total_tabs = 0 self._tmp_dir = None @@ -165,7 +166,7 @@ async def _conform_tabs(self, tabs=None) -> None: await asyncio.gather(*tasks) _logger.info("All navigates done, putting them all in queue.") for tab in kaleido_tabs: - await self._tabs_ready.put(tab) + await self.tabs_ready.put(tab) self._total_tabs = len(kaleido_tabs) _logger.debug("Tabs fully navigated/enabled/ready") @@ -217,12 +218,12 @@ async def _get_kaleido_tab(self) -> _KaleidoTab: A kaleido-tab from the queue. """ - _logger.info(f"Getting tab from queue (has {self._tabs_ready.qsize()})") + _logger.info(f"Getting tab from queue (has {self.tabs_ready.qsize()})") if not self._total_tabs: raise RuntimeError( "Before generating a figure, you must await `k.open()`.", ) - tab = await self._tabs_ready.get() + tab = await self.tabs_ready.get() _logger.info(f"Got {tab.tab.target_id[:4]}") return tab @@ -238,9 +239,9 @@ async def _return_kaleido_tab(self, tab): await tab.reload() _logger.info( f"Putting tab {tab.tab.target_id[:4]} back (queue size: " - f"{self._tabs_ready.qsize()}).", + f"{self.tabs_ready.qsize()}).", ) - await self._tabs_ready.put(tab) + await self.tabs_ready.put(tab) _logger.debug(f"{tab.tab.target_id[:4]} put back.") def _clean_tab_return_task(self, main_task, task): From d4a5bd4f6c1d302f3677262fb59ed5860cf84d03 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 11:24:44 -0500 Subject: [PATCH 006/167] Add some types to kaleido.py --- src/py/kaleido/kaleido.py | 50 ++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index a79cbec5..72c6c901 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -4,9 +4,10 @@ import asyncio import warnings -from collections.abc import Iterable +from collections.abc import AsyncIterable, Iterable from functools import partial from pathlib import Path +from typing import TYPE_CHECKING import choreographer as choreo import logistro @@ -18,6 +19,10 @@ from ._page_generator import PageGenerator from ._utils import ErrorEntry, warn_incompatible_plotly +if TYPE_CHECKING: + from types import TracebackType + from typing import Any, Callable, Coroutine + _logger = logistro.getLogger(__name__) # Show a warning if the installed Plotly version @@ -25,10 +30,10 @@ warn_incompatible_plotly() -def _make_printer(name): +def _make_printer(name: str) -> Callable[[str], Coroutine[Any, Any, None]]: """Create event printer for generic events. Helper function.""" - async def print_all(response): + async def print_all(response: str) -> None: _logger.debug2(f"{name}:{response}") return print_all @@ -54,7 +59,7 @@ class Kaleido(choreo.Browser): # not really render tasks _main_tasks: set[asyncio.Task] - async def close(self): + async def close(self) -> None: """Close the browser.""" if self._tmp_dir: self._tmp_dir.clean() @@ -68,14 +73,31 @@ async def close(self): _logger.info("Exiting Kaleido/Choreo") return await super().close() - async def __aexit__(self, exc_type, exc_value, exc_tb): + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: """Close the browser.""" _logger.info("Waiting for all cleanups to finish.") await asyncio.gather(*self._background_render_tasks, return_exceptions=True) _logger.info("Exiting Kaleido") return await super().__aexit__(exc_type, exc_value, exc_tb) - def __init__(self, *args, **kwargs): # noqa: D417 no args/kwargs in description + def __init__( # noqa: D417, PLR0913 no args/kwargs in description + self, + *args: Any, + page_generator: None | PageGenerator | str | Path = None, + n: int = 1, + timeout: int | None = 90, + width: int | None = None, + height: int | None = None, + stepper: bool = False, + plotlyjs: str | None = None, + mathjax: str | None = None, + **kwargs: Any, + ) -> None: """ Initialize Kaleido, a `choreo.Browser` wrapper adding kaleido functionality. @@ -105,14 +127,14 @@ def __init__(self, *args, **kwargs): # noqa: D417 no args/kwargs in description self._total_tabs = 0 self._tmp_dir = None - page = kwargs.pop("page_generator", None) - self._timeout = kwargs.pop("timeout", 90) - self._n = kwargs.pop("n", 1) - self._height = kwargs.pop("height", None) - self._width = kwargs.pop("width", None) - self._stepper = kwargs.pop("stepper", False) - self._plotlyjs = kwargs.pop("plotlyjs", None) - self._mathjax = kwargs.pop("mathjax", None) + page = page_generator + self._timeout = timeout + self._n = n + self._height = height + self._width = width + self._stepper = stepper + self._plotlyjs = plotlyjs + self._mathjax = mathjax if not kwargs.get("headless", True) and (self._height or self._width): warnings.warn( "Height and Width can only be used if headless=True, " From 583b8c836f1b36bf72f39b868f0d3b4f4b38e9e7 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 15:40:11 -0500 Subject: [PATCH 007/167] Upgrade choreographer for typing. --- src/py/pyproject.toml | 2 +- src/py/uv.lock | 290 +++++++++++++++++++++--------------------- 2 files changed, 146 insertions(+), 146 deletions(-) diff --git a/src/py/pyproject.toml b/src/py/pyproject.toml index ce0efb3b..392ddb25 100644 --- a/src/py/pyproject.toml +++ b/src/py/pyproject.toml @@ -26,7 +26,7 @@ maintainers = [ {name = "Andrew Pikul", email = "ajpikul@gmail.com"}, ] dependencies = [ - "choreographer>=1.0.7", + "choreographer>=1.0.10", "logistro>=1.0.8", "orjson>=3.10.15", "packaging", diff --git a/src/py/uv.lock b/src/py/uv.lock index cb129f3d..7206f1a3 100644 --- a/src/py/uv.lock +++ b/src/py/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.8" resolution-markers = [ "python_full_version >= '3.12'", @@ -29,15 +29,15 @@ wheels = [ [[package]] name = "choreographer" -version = "1.0.9" +version = "1.0.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "logistro" }, { name = "simplejson" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/2f/1de552325ce03732cca29534ca3e91136721f500666ea4fa2eb348eb237b/choreographer-1.0.9.tar.gz", hash = "sha256:36423b4b049cf2ec2a68fa4024bdd22d0c417d5421913ef62b6c0e89595b6895", size = 40462, upload-time = "2025-06-10T19:14:30.327Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/0e/88c2a0307e27f40bd0ce18e66ba2c8f7327b95b23adc51ea57a08cb96797/choreographer-1.0.10.tar.gz", hash = "sha256:7adf84a0d6a6054386d5cce013fdcadb2426479e49c9b0cb06af7d3712ed263c", size = 40455, upload-time = "2025-08-22T20:37:25.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/da/38f4a19fadd86416551871fa1cc385df61b0f54656e1def4892c148cfd8f/choreographer-1.0.9-py3-none-any.whl", hash = "sha256:b3277e30953843a83d3d730e49958a6be82013885d2a4f54b3950a3715191d7f", size = 51262, upload-time = "2025-06-10T19:14:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c2/82389f6e20098a414ddcf88d3b8032809b7a66e70f9bc5264f81beb24b4a/choreographer-1.0.10-py3-none-any.whl", hash = "sha256:b23ec2409f38ec89a544558eadeb19688746bab9a0f47e7115477d6e80a14a41", size = 51300, upload-time = "2025-08-22T20:37:24.372Z" }, ] [[package]] @@ -87,7 +87,7 @@ dependencies = [ { name = "choreographer" }, { name = "logistro" }, { name = "orjson", version = "3.10.15", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "orjson", version = "3.11.1", 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" }, ] @@ -97,10 +97,10 @@ dev = [ { name = "mypy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mypy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pandas", version = "2.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pandas", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pandas", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "plotly", extra = ["express"] }, { name = "poethepoet", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "poethepoet", version = "0.36.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "poethepoet", version = "0.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { 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'" }, { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -114,7 +114,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "choreographer", specifier = ">=1.0.7" }, + { name = "choreographer", specifier = ">=1.0.10" }, { name = "logistro", specifier = ">=1.0.8" }, { name = "orjson", specifier = ">=3.10.15" }, { name = "packaging" }, @@ -276,7 +276,7 @@ wheels = [ [[package]] name = "narwhals" -version = "2.0.1" +version = "2.1.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", @@ -284,9 +284,9 @@ resolution-markers = [ "python_full_version == '3.10.*'", "python_full_version == '3.9.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/8f/51d14e402c4f9d281a2e153a6a805cad5460088027a999faf264b54e7641/narwhals-2.0.1.tar.gz", hash = "sha256:235e61ca807bc21110ca36a4d53888ecc22c42dcdf50a7c886e10dde3fd7f38c", size = 525541, upload-time = "2025-07-29T08:39:04.81Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/f0/b0550d9b84759f4d045fd43da2f811e8b23dc2001e38c3254456da7f3adb/narwhals-2.1.2.tar.gz", hash = "sha256:afb9597e76d5b38c2c4b7c37d27a2418b8cc8049a66b8a5aca9581c92ae8f8bf", size = 533772, upload-time = "2025-08-15T08:24:50.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/26/43caf834e47c63883a5eddc02893b7fdbe6a0a4508ff6dc401907f3cc085/narwhals-2.0.1-py3-none-any.whl", hash = "sha256:837457e36a2ba1710c881fb69e1f79ce44fb81728c92ac378f70892a53af8ddb", size = 385436, upload-time = "2025-07-29T08:39:03.163Z" }, + { url = "https://files.pythonhosted.org/packages/a8/01/824fff6789ce92a53242d24b6f5f3a982df2f610c51020f934bf878d2a99/narwhals-2.1.2-py3-none-any.whl", hash = "sha256:136b2f533a4eb3245c54254f137c5d14cef5c4668cff67dc6e911a602acd3547", size = 392064, upload-time = "2025-08-15T08:24:48.788Z" }, ] [[package]] @@ -623,7 +623,7 @@ wheels = [ [[package]] name = "orjson" -version = "3.11.1" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", @@ -631,90 +631,90 @@ resolution-markers = [ "python_full_version == '3.10.*'", "python_full_version == '3.9.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/19/3b/fd9ff8ff64ae3900f11554d5cfc835fb73e501e043c420ad32ec574fe27f/orjson-3.11.1.tar.gz", hash = "sha256:48d82770a5fd88778063604c566f9c7c71820270c9cc9338d25147cbf34afd96", size = 5393373, upload-time = "2025-07-25T14:33:52.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/1d/5e0ae38788bdf0721326695e65fdf41405ed535f633eb0df0f06f57552fa/orjson-3.11.2.tar.gz", hash = "sha256:91bdcf5e69a8fd8e8bdb3de32b31ff01d2bd60c1e8d5fe7d5afabdcf19920309", size = 5470739, upload-time = "2025-08-12T15:12:28.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/8b/7dd88f416e2e5834fd9809d871f471aae7d12dfd83d4786166fa5a926601/orjson-3.11.1-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:92d771c492b64119456afb50f2dff3e03a2db8b5af0eba32c5932d306f970532", size = 241312, upload-time = "2025-07-25T14:31:52.841Z" }, - { url = "https://files.pythonhosted.org/packages/f3/5d/5bfc371bd010ffbec90e64338aa59abcb13ed94191112199048653ee2f34/orjson-3.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0085ef83a4141c2ed23bfec5fecbfdb1e95dd42fc8e8c76057bdeeec1608ea65", size = 132791, upload-time = "2025-07-25T14:31:55.547Z" }, - { url = "https://files.pythonhosted.org/packages/48/e2/c07854a6bad71e4249345efadb686c0aff250073bdab8ba9be7626af6516/orjson-3.11.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5caf7f13f2e1b4e137060aed892d4541d07dabc3f29e6d891e2383c7ed483440", size = 128690, upload-time = "2025-07-25T14:31:56.708Z" }, - { url = "https://files.pythonhosted.org/packages/48/e4/2e075348e7772aa1404d51d8df25ff4d6ee3daf682732cb21308e3b59c32/orjson-3.11.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f716bcc166524eddfcf9f13f8209ac19a7f27b05cf591e883419079d98c8c99d", size = 130646, upload-time = "2025-07-25T14:31:58.165Z" }, - { url = "https://files.pythonhosted.org/packages/97/09/50daacd3ac7ae564186924c8d1121940f2c78c64d6804dbe81dd735ab087/orjson-3.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:507d6012fab05465d8bf21f5d7f4635ba4b6d60132874e349beff12fb51af7fe", size = 132620, upload-time = "2025-07-25T14:31:59.226Z" }, - { url = "https://files.pythonhosted.org/packages/da/21/5f22093fa90e6d6fcf8111942b530a4ad19ee1cc0b06ddad4a63b16ab852/orjson-3.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1545083b0931f754c80fd2422a73d83bea7a6d1b6de104a5f2c8dd3d64c291e", size = 135121, upload-time = "2025-07-25T14:32:00.653Z" }, - { url = "https://files.pythonhosted.org/packages/48/90/77ad4bfa6bd400a3d241695e3e39975e32fe027aea5cb0b171bd2080c427/orjson-3.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e217ce3bad76351e1eb29ebe5ca630326f45cd2141f62620107a229909501a3", size = 131131, upload-time = "2025-07-25T14:32:01.821Z" }, - { url = "https://files.pythonhosted.org/packages/5a/64/d383675229f7ffd971b6ec6cdd3016b00877bb6b2d5fc1fd099c2ec2ad57/orjson-3.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06ef26e009304bda4df42e4afe518994cde6f89b4b04c0ff24021064f83f4fbb", size = 131025, upload-time = "2025-07-25T14:32:02.879Z" }, - { url = "https://files.pythonhosted.org/packages/d4/82/e4017d8d98597f6056afaf75021ff390154d1e2722c66ba45a4d50f82606/orjson-3.11.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:ba49683b87bea3ae1489a88e766e767d4f423a669a61270b6d6a7ead1c33bd65", size = 404464, upload-time = "2025-07-25T14:32:04.384Z" }, - { url = "https://files.pythonhosted.org/packages/77/7e/45c7f813c30d386c0168a32ce703494262458af6b222a3eeac1c0bb88822/orjson-3.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5072488fcc5cbcda2ece966d248e43ea1d222e19dd4c56d3f82747777f24d864", size = 146416, upload-time = "2025-07-25T14:32:05.57Z" }, - { url = "https://files.pythonhosted.org/packages/41/71/6ccb4d7875ec3349409960769a28349f477856f05de9fd961454c2b99230/orjson-3.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f58ae2bcd119226fe4aa934b5880fe57b8e97b69e51d5d91c88a89477a307016", size = 135497, upload-time = "2025-07-25T14:32:06.704Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ce/df8dac7da075962fdbfca55d53e3601aa910c9f23606033bf0f084835720/orjson-3.11.1-cp310-cp310-win32.whl", hash = "sha256:6723be919c07906781b9c63cc52dc7d2fb101336c99dd7e85d3531d73fb493f7", size = 136807, upload-time = "2025-07-25T14:32:08.303Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a0/f6c2be24709d1742d878b4530fa0c3f4a5e190d51397b680abbf44d11dbf/orjson-3.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:5fd44d69ddfdfb4e8d0d83f09d27a4db34930fba153fbf79f8d4ae8b47914e04", size = 131561, upload-time = "2025-07-25T14:32:09.444Z" }, - { url = "https://files.pythonhosted.org/packages/a5/92/7ab270b5b3df8d5b0d3e572ddf2f03c9f6a79726338badf1ec8594e1469d/orjson-3.11.1-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:15e2a57ce3b57c1a36acffcc02e823afefceee0a532180c2568c62213c98e3ef", size = 240918, upload-time = "2025-07-25T14:32:11.021Z" }, - { url = "https://files.pythonhosted.org/packages/80/41/df44684cfbd2e2e03bf9b09fdb14b7abcfff267998790b6acfb69ad435f0/orjson-3.11.1-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:17040a83ecaa130474af05bbb59a13cfeb2157d76385556041f945da936b1afd", size = 129386, upload-time = "2025-07-25T14:32:12.361Z" }, - { url = "https://files.pythonhosted.org/packages/c1/08/958f56edd18ba1827ad0c74b2b41a7ae0864718adee8ccb5d1a5528f8761/orjson-3.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a68f23f09e5626cc0867a96cf618f68b91acb4753d33a80bf16111fd7f9928c", size = 132508, upload-time = "2025-07-25T14:32:13.917Z" }, - { url = "https://files.pythonhosted.org/packages/cc/b6/5e56e189dacbf51e53ba8150c20e61ee746f6d57b697f5c52315ffc88a83/orjson-3.11.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47e07528bb6ccbd6e32a55e330979048b59bfc5518b47c89bc7ab9e3de15174a", size = 128501, upload-time = "2025-07-25T14:32:15.13Z" }, - { url = "https://files.pythonhosted.org/packages/fe/de/f6c301a514f5934405fd4b8f3d3efc758c911d06c3de3f4be1e30d675fa4/orjson-3.11.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3807cce72bf40a9d251d689cbec28d2efd27e0f6673709f948f971afd52cb09", size = 130465, upload-time = "2025-07-25T14:32:17.355Z" }, - { url = "https://files.pythonhosted.org/packages/47/08/f7dbaab87d6f05eebff2d7b8e6a8ed5f13b2fe3e3ae49472b527d03dbd7a/orjson-3.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b2dc7e88da4ca201c940f5e6127998d9e89aa64264292334dad62854bc7fc27", size = 132416, upload-time = "2025-07-25T14:32:18.933Z" }, - { url = "https://files.pythonhosted.org/packages/43/3f/dd5a185273b7ba6aa238cfc67bf9edaa1885ae51ce942bc1a71d0f99f574/orjson-3.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3091dad33ac9e67c0a550cfff8ad5be156e2614d6f5d2a9247df0627751a1495", size = 134924, upload-time = "2025-07-25T14:32:20.134Z" }, - { url = "https://files.pythonhosted.org/packages/db/ef/729d23510eaa81f0ce9d938d99d72dcf5e4ed3609d9d0bcf9c8a282cc41a/orjson-3.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ed0fce2307843b79a0c83de49f65b86197f1e2310de07af9db2a1a77a61ce4c", size = 130938, upload-time = "2025-07-25T14:32:21.769Z" }, - { url = "https://files.pythonhosted.org/packages/82/96/120feb6807f9e1f4c68fc842a0f227db8575eafb1a41b2537567b91c19d8/orjson-3.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a31e84782a18c30abd56774c0cfa7b9884589f4d37d9acabfa0504dad59bb9d", size = 130811, upload-time = "2025-07-25T14:32:22.931Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/4695e946a453fa22ff945da4b1ed0691b3f4ec86b828d398288db4a0ff79/orjson-3.11.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:26b6c821abf1ae515fbb8e140a2406c9f9004f3e52acb780b3dee9bfffddbd84", size = 404272, upload-time = "2025-07-25T14:32:25.238Z" }, - { url = "https://files.pythonhosted.org/packages/cd/7b/1c953e2c9e55af126c6cb678a30796deb46d7713abdeb706b8765929464c/orjson-3.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f857b3d134b36a8436f1e24dcb525b6b945108b30746c1b0b556200b5cb76d39", size = 146196, upload-time = "2025-07-25T14:32:26.909Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c2/bef5d3bc83f2e178592ff317e2cf7bd38ebc16b641f076ea49f27aadd1d3/orjson-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df146f2a14116ce80f7da669785fcb411406d8e80136558b0ecda4c924b9ac55", size = 135336, upload-time = "2025-07-25T14:32:28.22Z" }, - { url = "https://files.pythonhosted.org/packages/92/95/bc6006881ebdb4608ed900a763c3e3c6be0d24c3aadd62beb774f9464ec6/orjson-3.11.1-cp311-cp311-win32.whl", hash = "sha256:d777c57c1f86855fe5492b973f1012be776e0398571f7cc3970e9a58ecf4dc17", size = 136665, upload-time = "2025-07-25T14:32:29.976Z" }, - { url = "https://files.pythonhosted.org/packages/59/c3/1f2b9cc0c60ea2473d386fed2df2b25ece50aeb73c798d4669aadff3061e/orjson-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:e9a5fd589951f02ec2fcb8d69339258bbf74b41b104c556e6d4420ea5e059313", size = 131388, upload-time = "2025-07-25T14:32:31.595Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e5/40c97e5a6b85944022fe54b463470045b8651b7bb2f1e16a95c42812bf97/orjson-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:4cddbe41ee04fddad35d75b9cf3e3736ad0b80588280766156b94783167777af", size = 126786, upload-time = "2025-07-25T14:32:32.787Z" }, - { url = "https://files.pythonhosted.org/packages/98/77/e55513826b712807caadb2b733eee192c1df105c6bbf0d965c253b72f124/orjson-3.11.1-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2b7c8be96db3a977367250c6367793a3c5851a6ca4263f92f0b48d00702f9910", size = 240955, upload-time = "2025-07-25T14:32:34.056Z" }, - { url = "https://files.pythonhosted.org/packages/c9/88/a78132dddcc9c3b80a9fa050b3516bb2c996a9d78ca6fb47c8da2a80a696/orjson-3.11.1-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:72e18088f567bd4a45db5e3196677d9ed1605e356e500c8e32dd6e303167a13d", size = 129294, upload-time = "2025-07-25T14:32:35.323Z" }, - { url = "https://files.pythonhosted.org/packages/09/02/6591e0dcb2af6bceea96cb1b5f4b48c1445492a3ef2891ac4aa306bb6f73/orjson-3.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d346e2ae1ce17888f7040b65a5a4a0c9734cb20ffbd228728661e020b4c8b3a5", size = 132310, upload-time = "2025-07-25T14:32:36.53Z" }, - { url = "https://files.pythonhosted.org/packages/e9/36/c1cfbc617bcfa4835db275d5e0fe9bbdbe561a4b53d3b2de16540ec29c50/orjson-3.11.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4bda5426ebb02ceb806a7d7ec9ba9ee5e0c93fca62375151a7b1c00bc634d06b", size = 128529, upload-time = "2025-07-25T14:32:37.817Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bd/91a156c5df3aaf1d68b2ab5be06f1969955a8d3e328d7794f4338ac1d017/orjson-3.11.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10506cebe908542c4f024861102673db534fd2e03eb9b95b30d94438fa220abf", size = 130925, upload-time = "2025-07-25T14:32:39.03Z" }, - { url = "https://files.pythonhosted.org/packages/a3/4c/a65cc24e9a5f87c9833a50161ab97b5edbec98bec99dfbba13827549debc/orjson-3.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45202ee3f5494644e064c41abd1320497fb92fd31fc73af708708af664ac3b56", size = 132432, upload-time = "2025-07-25T14:32:40.619Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4d/3fc3e5d7115f4f7d01b481e29e5a79bcbcc45711a2723242787455424f40/orjson-3.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5adaf01b92e0402a9ac5c3ebe04effe2bbb115f0914a0a53d34ea239a746289", size = 135069, upload-time = "2025-07-25T14:32:41.84Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c6/7585aa8522af896060dc0cd7c336ba6c574ae854416811ee6642c505cc95/orjson-3.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6162a1a757a1f1f4a94bc6ffac834a3602e04ad5db022dd8395a54ed9dd51c81", size = 131045, upload-time = "2025-07-25T14:32:43.085Z" }, - { url = "https://files.pythonhosted.org/packages/6a/4e/b8a0a943793d2708ebc39e743c943251e08ee0f3279c880aefd8e9cb0c70/orjson-3.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:78404206977c9f946613d3f916727c189d43193e708d760ea5d4b2087d6b0968", size = 130597, upload-time = "2025-07-25T14:32:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/72/2b/7d30e2aed2f585d5d385fb45c71d9b16ba09be58c04e8767ae6edc6c9282/orjson-3.11.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:db48f8e81072e26df6cdb0e9fff808c28597c6ac20a13d595756cf9ba1fed48a", size = 404207, upload-time = "2025-07-25T14:32:45.612Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7e/772369ec66fcbce79477f0891918309594cd00e39b67a68d4c445d2ab754/orjson-3.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0c1e394e67ced6bb16fea7054d99fbdd99a539cf4d446d40378d4c06e0a8548d", size = 146628, upload-time = "2025-07-25T14:32:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/b4/c8/62bdb59229d7e393ae309cef41e32cc1f0b567b21dfd0742da70efb8b40c/orjson-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e7a840752c93d4eecd1378e9bb465c3703e127b58f675cd5c620f361b6cf57a4", size = 135449, upload-time = "2025-07-25T14:32:48.727Z" }, - { url = "https://files.pythonhosted.org/packages/02/47/1c99aa60e19f781424eabeaacd9e999eafe5b59c81ead4273b773f0f3af1/orjson-3.11.1-cp312-cp312-win32.whl", hash = "sha256:4537b0e09f45d2b74cb69c7f39ca1e62c24c0488d6bf01cd24673c74cd9596bf", size = 136653, upload-time = "2025-07-25T14:32:50.622Z" }, - { url = "https://files.pythonhosted.org/packages/31/9a/132999929a2892ab07e916669accecc83e5bff17e11a1186b4c6f23231f0/orjson-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:dbee6b050062540ae404530cacec1bf25e56e8d87d8d9b610b935afeb6725cae", size = 131426, upload-time = "2025-07-25T14:32:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/9c/77/d984ee5a1ca341090902e080b187721ba5d1573a8d9759e0c540975acfb2/orjson-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:f55e557d4248322d87c4673e085c7634039ff04b47bfc823b87149ae12bef60d", size = 126635, upload-time = "2025-07-25T14:32:53.2Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e9/880ef869e6f66279ce3a381a32afa0f34e29a94250146911eee029e56efc/orjson-3.11.1-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53cfefe4af059e65aabe9683f76b9c88bf34b4341a77d329227c2424e0e59b0e", size = 240835, upload-time = "2025-07-25T14:32:54.507Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1f/52039ef3d03eeea21763b46bc99ebe11d9de8510c72b7b5569433084a17e/orjson-3.11.1-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:93d5abed5a6f9e1b6f9b5bf6ed4423c11932b5447c2f7281d3b64e0f26c6d064", size = 129226, upload-time = "2025-07-25T14:32:55.908Z" }, - { url = "https://files.pythonhosted.org/packages/ee/da/59fdffc9465a760be2cd3764ef9cd5535eec8f095419f972fddb123b6d0e/orjson-3.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dbf06642f3db2966df504944cdd0eb68ca2717f0353bb20b20acd78109374a6", size = 132261, upload-time = "2025-07-25T14:32:57.538Z" }, - { url = "https://files.pythonhosted.org/packages/bb/5c/8610911c7e969db7cf928c8baac4b2f1e68d314bc3057acf5ca64f758435/orjson-3.11.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dddf4e78747fa7f2188273f84562017a3c4f0824485b78372513c1681ea7a894", size = 128614, upload-time = "2025-07-25T14:32:58.808Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a1/a1db9d4310d014c90f3b7e9b72c6fb162cba82c5f46d0b345669eaebdd3a/orjson-3.11.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa3fe8653c9f57f0e16f008e43626485b6723b84b2f741f54d1258095b655912", size = 130968, upload-time = "2025-07-25T14:33:00.038Z" }, - { url = "https://files.pythonhosted.org/packages/56/ff/11acd1fd7c38ea7a1b5d6bf582ae3da05931bee64620995eb08fd63c77fe/orjson-3.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6334d2382aff975a61f6f4d1c3daf39368b887c7de08f7c16c58f485dcf7adb2", size = 132439, upload-time = "2025-07-25T14:33:01.354Z" }, - { url = "https://files.pythonhosted.org/packages/70/f9/bb564dd9450bf8725e034a8ad7f4ae9d4710a34caf63b85ce1c0c6d40af0/orjson-3.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3d0855b643f259ee0cb76fe3df4c04483354409a520a902b067c674842eb6b8", size = 135299, upload-time = "2025-07-25T14:33:03.079Z" }, - { url = "https://files.pythonhosted.org/packages/94/bb/c8eafe6051405e241dda3691db4d9132d3c3462d1d10a17f50837dd130b4/orjson-3.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0eacdfeefd0a79987926476eb16e0245546bedeb8febbbbcf4b653e79257a8e4", size = 131004, upload-time = "2025-07-25T14:33:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/a2/40/bed8d7dcf1bd2df8813bf010a25f645863a2f75e8e0ebdb2b55784cf1a62/orjson-3.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ed07faf9e4873518c60480325dcbc16d17c59a165532cccfb409b4cdbaeff24", size = 130583, upload-time = "2025-07-25T14:33:05.768Z" }, - { url = "https://files.pythonhosted.org/packages/57/e7/cfa2eb803ad52d74fbb5424a429b5be164e51d23f1d853e5e037173a5c48/orjson-3.11.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d308dd578ae3658f62bb9eba54801533225823cd3248c902be1ebc79b5e014", size = 404218, upload-time = "2025-07-25T14:33:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/d5/21/bc703af5bc6e9c7e18dcf4404dcc4ec305ab9bb6c82d3aee5952c0c56abf/orjson-3.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c4aa13ca959ba6b15c0a98d3d204b850f9dc36c08c9ce422ffb024eb30d6e058", size = 146605, upload-time = "2025-07-25T14:33:08.55Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d26a0150534c4965a06f556aa68bf3c3b82999d5d7b0facd3af7b390c4af/orjson-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:be3d0653322abc9b68e5bcdaee6cfd58fcbe9973740ab222b87f4d687232ab1f", size = 135434, upload-time = "2025-07-25T14:33:09.967Z" }, - { url = "https://files.pythonhosted.org/packages/89/b6/1cb28365f08cbcffc464f8512320c6eb6db6a653f03d66de47ea3c19385f/orjson-3.11.1-cp313-cp313-win32.whl", hash = "sha256:4dd34e7e2518de8d7834268846f8cab7204364f427c56fb2251e098da86f5092", size = 136596, upload-time = "2025-07-25T14:33:11.333Z" }, - { url = "https://files.pythonhosted.org/packages/f9/35/7870d0d3ed843652676d84d8a6038791113eacc85237b673b925802826b8/orjson-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:d6895d32032b6362540e6d0694b19130bb4f2ad04694002dce7d8af588ca5f77", size = 131319, upload-time = "2025-07-25T14:33:12.614Z" }, - { url = "https://files.pythonhosted.org/packages/b7/3e/5bcd50fd865eb664d4edfdaaaff51e333593ceb5695a22c0d0a0d2b187ba/orjson-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:bb7c36d5d3570fcbb01d24fa447a21a7fe5a41141fd88e78f7994053cc4e28f4", size = 126613, upload-time = "2025-07-25T14:33:13.927Z" }, - { url = "https://files.pythonhosted.org/packages/61/d8/0a5cd31ed100b4e569e143cb0cddefc21f0bcb8ce284f44bca0bb0e10f3d/orjson-3.11.1-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:7b71ef394327b3d0b39f6ea7ade2ecda2731a56c6a7cbf0d6a7301203b92a89b", size = 240819, upload-time = "2025-07-25T14:33:15.223Z" }, - { url = "https://files.pythonhosted.org/packages/b9/95/7eb2c76c92192ceca16bc81845ff100bbb93f568b4b94d914b6a4da47d61/orjson-3.11.1-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:77c0fe28ed659b62273995244ae2aa430e432c71f86e4573ab16caa2f2e3ca5e", size = 129218, upload-time = "2025-07-25T14:33:16.637Z" }, - { url = "https://files.pythonhosted.org/packages/da/84/e6b67f301b18adbbc346882f456bea44daebbd032ba725dbd7b741e3a7f1/orjson-3.11.1-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:1495692f1f1ba2467df429343388a0ed259382835922e124c0cfdd56b3d1f727", size = 132238, upload-time = "2025-07-25T14:33:17.934Z" }, - { url = "https://files.pythonhosted.org/packages/84/78/a45a86e29d9b2f391f9d00b22da51bc4b46b86b788fd42df2c5fcf3e8005/orjson-3.11.1-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:08c6a762fca63ca4dc04f66c48ea5d2428db55839fec996890e1bfaf057b658c", size = 130998, upload-time = "2025-07-25T14:33:19.282Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8f/6eb3ee6760d93b2ce996a8529164ee1f5bafbdf64b74c7314b68db622b32/orjson-3.11.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e26794fe3976810b2c01fda29bd9ac7c91a3c1284b29cc9a383989f7b614037", size = 130559, upload-time = "2025-07-25T14:33:20.589Z" }, - { url = "https://files.pythonhosted.org/packages/1b/78/9572ae94bdba6813917c9387e7834224c011ea6b4530ade07d718fd31598/orjson-3.11.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4b4b4f8f0b1d3ef8dc73e55363a0ffe012a42f4e2f1a140bf559698dca39b3fa", size = 404231, upload-time = "2025-07-25T14:33:22.019Z" }, - { url = "https://files.pythonhosted.org/packages/1f/a3/68381ad0757e084927c5ee6cfdeab1c6c89405949ee493db557e60871c4c/orjson-3.11.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:848be553ea35aa89bfefbed2e27c8a41244c862956ab8ba00dc0b27e84fd58de", size = 146658, upload-time = "2025-07-25T14:33:23.675Z" }, - { url = "https://files.pythonhosted.org/packages/00/db/fac56acf77aab778296c3f541a3eec643266f28ecd71d6c0cba251e47655/orjson-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c964c29711a4b1df52f8d9966f015402a6cf87753a406c1c4405c407dd66fd45", size = 135443, upload-time = "2025-07-25T14:33:25.04Z" }, - { url = "https://files.pythonhosted.org/packages/76/b1/326fa4b87426197ead61c1eec2eeb3babc9eb33b480ac1f93894e40c8c08/orjson-3.11.1-cp314-cp314-win32.whl", hash = "sha256:33aada2e6b6bc9c540d396528b91e666cedb383740fee6e6a917f561b390ecb1", size = 136643, upload-time = "2025-07-25T14:33:26.449Z" }, - { url = "https://files.pythonhosted.org/packages/0f/8e/2987ae2109f3bfd39680f8a187d1bc09ad7f8fb019dcdc719b08c7242ade/orjson-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:68e10fd804e44e36188b9952543e3fa22f5aa8394da1b5283ca2b423735c06e8", size = 131324, upload-time = "2025-07-25T14:33:27.896Z" }, - { url = "https://files.pythonhosted.org/packages/21/5f/253e08e6974752b124fbf3a4de3ad53baa766b0cb4a333d47706d307e396/orjson-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:f3cf6c07f8b32127d836be8e1c55d4f34843f7df346536da768e9f73f22078a1", size = 126605, upload-time = "2025-07-25T14:33:29.244Z" }, - { url = "https://files.pythonhosted.org/packages/f5/64/ce5c07420fe7367bd3da769161f07ae54b35c552468c6eb7947c023a25c6/orjson-3.11.1-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3d593a9e0bccf2c7401ae53625b519a7ad7aa555b1c82c0042b322762dc8af4e", size = 241861, upload-time = "2025-07-25T14:33:30.585Z" }, - { url = "https://files.pythonhosted.org/packages/94/17/7894ff2867e83d0d5cdda6e41210963a88764b292ec7a91fa93bcb5afd9e/orjson-3.11.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0baad413c498fc1eef568504f11ea46bc71f94b845c075e437da1e2b85b4fb86", size = 132485, upload-time = "2025-07-25T14:33:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/8e/38/e8f907733e281e65ba912be552fe5ad5b53f0fdddaa0b43c3a9bc0bce5df/orjson-3.11.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:22cf17ae1dae3f9b5f37bfcdba002ed22c98bbdb70306e42dc18d8cc9b50399a", size = 128513, upload-time = "2025-07-25T14:33:33.571Z" }, - { url = "https://files.pythonhosted.org/packages/d5/49/d6d0f23036a16c9909ca4cb09d53b2bf9341e7b1ae7d03ded302a3673448/orjson-3.11.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e855c1e97208133ce88b3ef6663c9a82ddf1d09390cd0856a1638deee0390c3c", size = 130462, upload-time = "2025-07-25T14:33:35.061Z" }, - { url = "https://files.pythonhosted.org/packages/04/70/df75afdfe6d3c027c03d656f0a5074159ace27a24dbf22d4af7fabf811df/orjson-3.11.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5861c5f7acff10599132854c70ab10abf72aebf7c627ae13575e5f20b1ab8fe", size = 132438, upload-time = "2025-07-25T14:33:36.893Z" }, - { url = "https://files.pythonhosted.org/packages/56/ef/938ae6995965cc7884d8460177bed20248769d1edf99d1904dfd46eebd7d/orjson-3.11.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1e6415c5b5ff3a616a6dafad7b6ec303a9fc625e9313c8e1268fb1370a63dcb", size = 134928, upload-time = "2025-07-25T14:33:38.755Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2c/97be96e9ed22123724611c8511f306a69e6cd0273d4c6424edda5716d108/orjson-3.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:912579642f5d7a4a84d93c5eed8daf0aa34e1f2d3f4dc6571a8e418703f5701e", size = 130903, upload-time = "2025-07-25T14:33:40.585Z" }, - { url = "https://files.pythonhosted.org/packages/86/ed/7cf17c1621a5a4c6716dfa8099dc9a4153cc8bd402195ae9028d7e5286e3/orjson-3.11.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2092e1d3b33f64e129ff8271642afddc43763c81f2c30823b4a4a4a5f2ea5b55", size = 130793, upload-time = "2025-07-25T14:33:42.397Z" }, - { url = "https://files.pythonhosted.org/packages/5e/72/add1805918b6af187c193895d38bddc7717eea30d1ea8b25833a9668b469/orjson-3.11.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:b8ac64caba1add2c04e9cd4782d4d0c4d6c554b7a3369bdec1eed7854c98db7b", size = 404283, upload-time = "2025-07-25T14:33:44.035Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f1/b27c05bab8b49ff2fb30e6c42e8602ae51d6c9dd19564031da37f7ea61ba/orjson-3.11.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:23196b826ebc85c43f8e27bee0ab33c5fb13a29ea47fb4fcd6ebb1e660eb0252", size = 146169, upload-time = "2025-07-25T14:33:46.036Z" }, - { url = "https://files.pythonhosted.org/packages/91/5b/5a2cdc081bc2093708726887980d8f0c7c0edc31ab0d3c5ccc1db70ede0e/orjson-3.11.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f2d3364cfad43003f1e3d564a069c8866237cca30f9c914b26ed2740b596ed00", size = 135304, upload-time = "2025-07-25T14:33:47.519Z" }, - { url = "https://files.pythonhosted.org/packages/01/7f/fe09ebaecbaec6a741b29f79ccbbe38736dff51e8413f334067ad914df26/orjson-3.11.1-cp39-cp39-win32.whl", hash = "sha256:20b0dca94ea4ebe4628330de50975b35817a3f52954c1efb6d5d0498a3bbe581", size = 136652, upload-time = "2025-07-25T14:33:49.38Z" }, - { url = "https://files.pythonhosted.org/packages/97/2f/71fe70d7d06087d8abef423843d880e3d4cf21cfc38c299feebb0a98f7c1/orjson-3.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:200c3ad7ed8b5d31d49143265dfebd33420c4b61934ead16833b5cd2c3d241be", size = 131373, upload-time = "2025-07-25T14:33:51.359Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7b/7aebe925c6b1c46c8606a960fe1d6b681fccd4aaf3f37cd647c3309d6582/orjson-3.11.2-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d6b8a78c33496230a60dc9487118c284c15ebdf6724386057239641e1eb69761", size = 226896, upload-time = "2025-08-12T15:10:22.02Z" }, + { url = "https://files.pythonhosted.org/packages/7d/39/c952c9b0d51063e808117dd1e53668a2e4325cc63cfe7df453d853ee8680/orjson-3.11.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc04036eeae11ad4180d1f7b5faddb5dab1dee49ecd147cd431523869514873b", size = 111845, upload-time = "2025-08-12T15:10:24.963Z" }, + { url = "https://files.pythonhosted.org/packages/f5/dc/90b7f29be38745eeacc30903b693f29fcc1097db0c2a19a71ffb3e9f2a5f/orjson-3.11.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c04325839c5754c253ff301cee8aaed7442d974860a44447bb3be785c411c27", size = 116395, upload-time = "2025-08-12T15:10:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/10/c2/fe84ba63164c22932b8d59b8810e2e58590105293a259e6dd1bfaf3422c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32769e04cd7fdc4a59854376211145a1bbbc0aea5e9d6c9755d3d3c301d7c0df", size = 118768, upload-time = "2025-08-12T15:10:27.605Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ce/d9748ec69b1a4c29b8e2bab8233e8c41c583c69f515b373f1fb00247d8c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff285d14917ea1408a821786e3677c5261fa6095277410409c694b8e7720ae0", size = 120887, upload-time = "2025-08-12T15:10:29.153Z" }, + { url = "https://files.pythonhosted.org/packages/c1/66/b90fac8e4a76e83f981912d7f9524d402b31f6c1b8bff3e498aa321c326c/orjson-3.11.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2662f908114864b63ff75ffe6ffacf996418dd6cc25e02a72ad4bda81b1ec45a", size = 123650, upload-time = "2025-08-12T15:10:30.602Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/56143898d1689c7f915ac67703efb97e8f2f8d5805ce8c2c3fd0f2bb6e3d/orjson-3.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab463cf5d08ad6623a4dac1badd20e88a5eb4b840050c4812c782e3149fe2334", size = 121287, upload-time = "2025-08-12T15:10:31.868Z" }, + { url = "https://files.pythonhosted.org/packages/80/de/f9c6d00c127be766a3739d0d85b52a7c941e437d8dd4d573e03e98d0f89c/orjson-3.11.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:64414241bde943cbf3c00d45fcb5223dca6d9210148ba984aae6b5d63294502b", size = 119637, upload-time = "2025-08-12T15:10:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/ab70c7627022d395c1b4eb5badf6196b7144e82b46a3a17ed2354f9e592d/orjson-3.11.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7773e71c0ae8c9660192ff144a3d69df89725325e3d0b6a6bb2c50e5ebaf9b84", size = 392478, upload-time = "2025-08-12T15:10:34.669Z" }, + { url = "https://files.pythonhosted.org/packages/77/91/d890b873b69311db4fae2624c5603c437df9c857fb061e97706dac550a77/orjson-3.11.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:652ca14e283b13ece35bf3a86503c25592f294dbcfc5bb91b20a9c9a62a3d4be", size = 134343, upload-time = "2025-08-12T15:10:35.978Z" }, + { url = "https://files.pythonhosted.org/packages/47/16/1aa248541b4830274a079c4aeb2aa5d1ff17c3f013b1d0d8d16d0848f3de/orjson-3.11.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:26e99e98df8990ecfe3772bbdd7361f602149715c2cbc82e61af89bfad9528a4", size = 123887, upload-time = "2025-08-12T15:10:37.601Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/7419833c55ac8b5f385d00c02685a260da1f391e900fc5c3e0b797e0d506/orjson-3.11.2-cp310-cp310-win32.whl", hash = "sha256:5814313b3e75a2be7fe6c7958201c16c4560e21a813dbad25920752cecd6ad66", size = 124560, upload-time = "2025-08-12T15:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/74/f8/27ca7ef3e194c462af32ce1883187f5ec483650c559166f0de59c4c2c5f0/orjson-3.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc471ce2225ab4c42ca672f70600d46a8b8e28e8d4e536088c1ccdb1d22b35ce", size = 119700, upload-time = "2025-08-12T15:10:40.911Z" }, + { url = "https://files.pythonhosted.org/packages/78/7d/e295df1ac9920cbb19fb4c1afa800e86f175cb657143aa422337270a4782/orjson-3.11.2-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:888b64ef7eaeeff63f773881929434a5834a6a140a63ad45183d59287f07fc6a", size = 226502, upload-time = "2025-08-12T15:10:42.284Z" }, + { url = "https://files.pythonhosted.org/packages/65/21/ffb0f10ea04caf418fb4e7ad1fda4b9ab3179df9d7a33b69420f191aadd5/orjson-3.11.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:83387cc8b26c9fa0ae34d1ea8861a7ae6cff8fb3e346ab53e987d085315a728e", size = 115999, upload-time = "2025-08-12T15:10:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/d5/8da1e252ac3353d92e6f754ee0c85027c8a2cda90b6899da2be0df3ef83d/orjson-3.11.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7e35f003692c216d7ee901b6b916b5734d6fc4180fcaa44c52081f974c08e17", size = 111563, upload-time = "2025-08-12T15:10:45.301Z" }, + { url = "https://files.pythonhosted.org/packages/4f/81/baabc32e52c570b0e4e1044b1bd2ccbec965e0de3ba2c13082255efa2006/orjson-3.11.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a0a4c29ae90b11d0c00bcc31533854d89f77bde2649ec602f512a7e16e00640", size = 116222, upload-time = "2025-08-12T15:10:46.92Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/da2ad55ad80b49b560dce894c961477d0e76811ee6e614b301de9f2f8728/orjson-3.11.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:585d712b1880f68370108bc5534a257b561672d1592fae54938738fe7f6f1e33", size = 118594, upload-time = "2025-08-12T15:10:48.488Z" }, + { url = "https://files.pythonhosted.org/packages/61/be/014f7eab51449f3c894aa9bbda2707b5340c85650cb7d0db4ec9ae280501/orjson-3.11.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d08e342a7143f8a7c11f1c4033efe81acbd3c98c68ba1b26b96080396019701f", size = 120700, upload-time = "2025-08-12T15:10:49.811Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ae/c217903a30c51341868e2d8c318c59a8413baa35af54d7845071c8ccd6fe/orjson-3.11.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c0f84fc50398773a702732c87cd622737bf11c0721e6db3041ac7802a686fb", size = 123433, upload-time = "2025-08-12T15:10:51.06Z" }, + { url = "https://files.pythonhosted.org/packages/57/c2/b3c346f78b1ff2da310dd300cb0f5d32167f872b4d3bb1ad122c889d97b0/orjson-3.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:140f84e3c8d4c142575898c91e3981000afebf0333df753a90b3435d349a5fe5", size = 121061, upload-time = "2025-08-12T15:10:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/00/c8/c97798f6010327ffc75ad21dd6bca11ea2067d1910777e798c2849f1c68f/orjson-3.11.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96304a2b7235e0f3f2d9363ddccdbfb027d27338722fe469fe656832a017602e", size = 119410, upload-time = "2025-08-12T15:10:53.692Z" }, + { url = "https://files.pythonhosted.org/packages/37/fd/df720f7c0e35694617b7f95598b11a2cb0374661d8389703bea17217da53/orjson-3.11.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d7612bb227d5d9582f1f50a60bd55c64618fc22c4a32825d233a4f2771a428a", size = 392294, upload-time = "2025-08-12T15:10:55.079Z" }, + { url = "https://files.pythonhosted.org/packages/ba/52/0120d18f60ab0fe47531d520372b528a45c9a25dcab500f450374421881c/orjson-3.11.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a134587d18fe493befc2defffef2a8d27cfcada5696cb7234de54a21903ae89a", size = 134134, upload-time = "2025-08-12T15:10:56.568Z" }, + { url = "https://files.pythonhosted.org/packages/ec/10/1f967671966598366de42f07e92b0fc694ffc66eafa4b74131aeca84915f/orjson-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0b84455e60c4bc12c1e4cbaa5cfc1acdc7775a9da9cec040e17232f4b05458bd", size = 123745, upload-time = "2025-08-12T15:10:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/43/eb/76081238671461cfd0f47e0c24f408ffa66184237d56ef18c33e86abb612/orjson-3.11.2-cp311-cp311-win32.whl", hash = "sha256:f0660efeac223f0731a70884e6914a5f04d613b5ae500744c43f7bf7b78f00f9", size = 124393, upload-time = "2025-08-12T15:10:59.267Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/cc598c1811ba9ba935171267b02e377fc9177489efce525d478a2999d9cc/orjson-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:955811c8405251d9e09cbe8606ad8fdef49a451bcf5520095a5ed38c669223d8", size = 119561, upload-time = "2025-08-12T15:11:00.559Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/c48011750f0489006f7617b0a3cebc8230f36d11a34e7e9aca2085f07792/orjson-3.11.2-cp311-cp311-win_arm64.whl", hash = "sha256:2e4d423a6f838552e3a6d9ec734b729f61f88b1124fd697eab82805ea1a2a97d", size = 114186, upload-time = "2025-08-12T15:11:01.931Z" }, + { url = "https://files.pythonhosted.org/packages/40/02/46054ebe7996a8adee9640dcad7d39d76c2000dc0377efa38e55dc5cbf78/orjson-3.11.2-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:901d80d349d8452162b3aa1afb82cec5bee79a10550660bc21311cc61a4c5486", size = 226528, upload-time = "2025-08-12T15:11:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/6b6f0b4d8aea1137436546b990f71be2cd8bd870aa2f5aa14dba0fcc95dc/orjson-3.11.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:cf3bd3967a360e87ee14ed82cb258b7f18c710dacf3822fb0042a14313a673a1", size = 115931, upload-time = "2025-08-12T15:11:04.759Z" }, + { url = "https://files.pythonhosted.org/packages/ae/05/4205cc97c30e82a293dd0d149b1a89b138ebe76afeca66fc129fa2aa4e6a/orjson-3.11.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26693dde66910078229a943e80eeb99fdce6cd2c26277dc80ead9f3ab97d2131", size = 111382, upload-time = "2025-08-12T15:11:06.468Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/b8a951a93caa821f9272a7c917115d825ae2e4e8768f5ddf37968ec9de01/orjson-3.11.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad4c8acb50a28211c33fc7ef85ddf5cb18d4636a5205fd3fa2dce0411a0e30c", size = 116271, upload-time = "2025-08-12T15:11:07.845Z" }, + { url = "https://files.pythonhosted.org/packages/17/03/1006c7f8782d5327439e26d9b0ec66500ea7b679d4bbb6b891d2834ab3ee/orjson-3.11.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:994181e7f1725bb5f2d481d7d228738e0743b16bf319ca85c29369c65913df14", size = 119086, upload-time = "2025-08-12T15:11:09.329Z" }, + { url = "https://files.pythonhosted.org/packages/44/61/57d22bc31f36a93878a6f772aea76b2184102c6993dea897656a66d18c74/orjson-3.11.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbb79a0476393c07656b69c8e763c3cc925fa8e1d9e9b7d1f626901bb5025448", size = 120724, upload-time = "2025-08-12T15:11:10.674Z" }, + { url = "https://files.pythonhosted.org/packages/78/a9/4550e96b4c490c83aea697d5347b8f7eb188152cd7b5a38001055ca5b379/orjson-3.11.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191ed27a1dddb305083d8716af413d7219f40ec1d4c9b0e977453b4db0d6fb6c", size = 123577, upload-time = "2025-08-12T15:11:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/3a/86/09b8cb3ebd513d708ef0c92d36ac3eebda814c65c72137b0a82d6d688fc4/orjson-3.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0afb89f16f07220183fd00f5f297328ed0a68d8722ad1b0c8dcd95b12bc82804", size = 121195, upload-time = "2025-08-12T15:11:13.399Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/7b40b39ac2c1c644d4644e706d0de6c9999764341cd85f2a9393cb387661/orjson-3.11.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ab6e6b4e93b1573a026b6ec16fca9541354dd58e514b62c558b58554ae04307", size = 119234, upload-time = "2025-08-12T15:11:15.134Z" }, + { url = "https://files.pythonhosted.org/packages/40/7c/bb6e7267cd80c19023d44d8cbc4ea4ed5429fcd4a7eb9950f50305697a28/orjson-3.11.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9cb23527efb61fb75527df55d20ee47989c4ee34e01a9c98ee9ede232abf6219", size = 392250, upload-time = "2025-08-12T15:11:16.604Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6730ace05583dbca7c1b406d59f4266e48cd0d360566e71482420fb849fc/orjson-3.11.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a4dd1268e4035af21b8a09e4adf2e61f87ee7bf63b86d7bb0a237ac03fad5b45", size = 134572, upload-time = "2025-08-12T15:11:18.205Z" }, + { url = "https://files.pythonhosted.org/packages/96/0f/7d3e03a30d5aac0432882b539a65b8c02cb6dd4221ddb893babf09c424cc/orjson-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff8b155b145eaf5a9d94d2c476fbe18d6021de93cf36c2ae2c8c5b775763f14e", size = 123869, upload-time = "2025-08-12T15:11:19.554Z" }, + { url = "https://files.pythonhosted.org/packages/45/80/1513265eba6d4a960f078f4b1d2bff94a571ab2d28c6f9835e03dfc65cc6/orjson-3.11.2-cp312-cp312-win32.whl", hash = "sha256:ae3bb10279d57872f9aba68c9931aa71ed3b295fa880f25e68da79e79453f46e", size = 124430, upload-time = "2025-08-12T15:11:20.914Z" }, + { url = "https://files.pythonhosted.org/packages/fb/61/eadf057b68a332351eeb3d89a4cc538d14f31cd8b5ec1b31a280426ccca2/orjson-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:d026e1967239ec11a2559b4146a61d13914504b396f74510a1c4d6b19dfd8732", size = 119598, upload-time = "2025-08-12T15:11:22.372Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3f/7f4b783402143d965ab7e9a2fc116fdb887fe53bdce7d3523271cd106098/orjson-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:59f8d5ad08602711af9589375be98477d70e1d102645430b5a7985fdbf613b36", size = 114052, upload-time = "2025-08-12T15:11:23.762Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/0dd6b4750eb556ae4e2c6a9cb3e219ec642e9c6d95f8ebe5dc9020c67204/orjson-3.11.2-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a079fdba7062ab396380eeedb589afb81dc6683f07f528a03b6f7aae420a0219", size = 226419, upload-time = "2025-08-12T15:11:25.517Z" }, + { url = "https://files.pythonhosted.org/packages/44/d5/e67f36277f78f2af8a4690e0c54da6b34169812f807fd1b4bfc4dbcf9558/orjson-3.11.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:6a5f62ebbc530bb8bb4b1ead103647b395ba523559149b91a6c545f7cd4110ad", size = 115803, upload-time = "2025-08-12T15:11:27.357Z" }, + { url = "https://files.pythonhosted.org/packages/24/37/ff8bc86e0dacc48f07c2b6e20852f230bf4435611bab65e3feae2b61f0ae/orjson-3.11.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7df6c7b8b0931feb3420b72838c3e2ba98c228f7aa60d461bc050cf4ca5f7b2", size = 111337, upload-time = "2025-08-12T15:11:28.805Z" }, + { url = "https://files.pythonhosted.org/packages/b9/25/37d4d3e8079ea9784ea1625029988e7f4594ce50d4738b0c1e2bf4a9e201/orjson-3.11.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f59dfea7da1fced6e782bb3699718088b1036cb361f36c6e4dd843c5111aefe", size = 116222, upload-time = "2025-08-12T15:11:30.18Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/a63fd9c07fce3b4193dcc1afced5dd4b0f3a24e27556604e9482b32189c9/orjson-3.11.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edf49146520fef308c31aa4c45b9925fd9c7584645caca7c0c4217d7900214ae", size = 119020, upload-time = "2025-08-12T15:11:31.59Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b6/400792b8adc3079a6b5d649264a3224d6342436d9fac9a0ed4abc9dc4596/orjson-3.11.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50995bbeb5d41a32ad15e023305807f561ac5dcd9bd41a12c8d8d1d2c83e44e6", size = 120721, upload-time = "2025-08-12T15:11:33.035Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/31ab8f8c699eb9e65af8907889a0b7fef74c1d2b23832719a35da7bb0c58/orjson-3.11.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cc42960515076eb639b705f105712b658c525863d89a1704d984b929b0577d1", size = 123574, upload-time = "2025-08-12T15:11:34.433Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a6/ce4287c412dff81878f38d06d2c80845709c60012ca8daf861cb064b4574/orjson-3.11.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56777cab2a7b2a8ea687fedafb84b3d7fdafae382165c31a2adf88634c432fa", size = 121225, upload-time = "2025-08-12T15:11:36.133Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/7a881b2aef4fed0287d2a4fbb029d01ed84fa52b4a68da82bdee5e50598e/orjson-3.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07349e88025b9b5c783077bf7a9f401ffbfb07fd20e86ec6fc5b7432c28c2c5e", size = 119201, upload-time = "2025-08-12T15:11:37.642Z" }, + { url = "https://files.pythonhosted.org/packages/cf/98/a325726b37f7512ed6338e5e65035c3c6505f4e628b09a5daf0419f054ea/orjson-3.11.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:45841fbb79c96441a8c58aa29ffef570c5df9af91f0f7a9572e5505e12412f15", size = 392193, upload-time = "2025-08-12T15:11:39.153Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4f/a7194f98b0ce1d28190e0c4caa6d091a3fc8d0107ad2209f75c8ba398984/orjson-3.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13d8d8db6cd8d89d4d4e0f4161acbbb373a4d2a4929e862d1d2119de4aa324ac", size = 134548, upload-time = "2025-08-12T15:11:40.768Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/b84caa2986c3f472dc56343ddb0167797a708a8d5c3be043e1e2677b55df/orjson-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51da1ee2178ed09c00d09c1b953e45846bbc16b6420965eb7a913ba209f606d8", size = 123798, upload-time = "2025-08-12T15:11:42.164Z" }, + { url = "https://files.pythonhosted.org/packages/9c/5b/e398449080ce6b4c8fcadad57e51fa16f65768e1b142ba90b23ac5d10801/orjson-3.11.2-cp313-cp313-win32.whl", hash = "sha256:51dc033df2e4a4c91c0ba4f43247de99b3cbf42ee7a42ee2b2b2f76c8b2f2cb5", size = 124402, upload-time = "2025-08-12T15:11:44.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/429e4608e124debfc4790bfc37131f6958e59510ba3b542d5fc163be8e5f/orjson-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:29d91d74942b7436f29b5d1ed9bcfc3f6ef2d4f7c4997616509004679936650d", size = 119498, upload-time = "2025-08-12T15:11:45.864Z" }, + { url = "https://files.pythonhosted.org/packages/7b/04/f8b5f317cce7ad3580a9ad12d7e2df0714dfa8a83328ecddd367af802f5b/orjson-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:4ca4fb5ac21cd1e48028d4f708b1bb13e39c42d45614befd2ead004a8bba8535", size = 114051, upload-time = "2025-08-12T15:11:47.555Z" }, + { url = "https://files.pythonhosted.org/packages/74/83/2c363022b26c3c25b3708051a19d12f3374739bb81323f05b284392080c0/orjson-3.11.2-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3dcba7101ea6a8d4ef060746c0f2e7aa8e2453a1012083e1ecce9726d7554cb7", size = 226406, upload-time = "2025-08-12T15:11:49.445Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a7/aa3c973de0b33fc93b4bd71691665ffdfeae589ea9d0625584ab10a7d0f5/orjson-3.11.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:15d17bdb76a142e1f55d91913e012e6e6769659daa6bfef3ef93f11083137e81", size = 115788, upload-time = "2025-08-12T15:11:50.992Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/e45f233dfd09fdbb052ec46352363dca3906618e1a2b264959c18f809d0b/orjson-3.11.2-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:53c9e81768c69d4b66b8876ec3c8e431c6e13477186d0db1089d82622bccd19f", size = 111318, upload-time = "2025-08-12T15:11:52.495Z" }, + { url = "https://files.pythonhosted.org/packages/3e/23/cf5a73c4da6987204cbbf93167f353ff0c5013f7c5e5ef845d4663a366da/orjson-3.11.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d4f13af59a7b84c1ca6b8a7ab70d608f61f7c44f9740cd42409e6ae7b6c8d8b7", size = 121231, upload-time = "2025-08-12T15:11:53.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/1d/47468a398ae68a60cc21e599144e786e035bb12829cb587299ecebc088f1/orjson-3.11.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bde64aa469b5ee46cc960ed241fae3721d6a8801dacb2ca3466547a2535951e4", size = 119204, upload-time = "2025-08-12T15:11:55.409Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d9/f99433d89b288b5bc8836bffb32a643f805e673cf840ef8bab6e73ced0d1/orjson-3.11.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b5ca86300aeb383c8fa759566aca065878d3d98c3389d769b43f0a2e84d52c5f", size = 392237, upload-time = "2025-08-12T15:11:57.18Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dc/1b9d80d40cebef603325623405136a29fb7d08c877a728c0943dd066c29a/orjson-3.11.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24e32a558ebed73a6a71c8f1cbc163a7dd5132da5270ff3d8eeb727f4b6d1bc7", size = 134578, upload-time = "2025-08-12T15:11:58.844Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/72e7a4c5b6485ef4e83ef6aba7f1dd041002bad3eb5d1d106ca5b0fc02c6/orjson-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e36319a5d15b97e4344110517450396845cc6789aed712b1fbf83c1bd95792f6", size = 123799, upload-time = "2025-08-12T15:12:00.352Z" }, + { url = "https://files.pythonhosted.org/packages/c8/3e/a3d76b392e7acf9b34dc277171aad85efd6accc75089bb35b4c614990ea9/orjson-3.11.2-cp314-cp314-win32.whl", hash = "sha256:40193ada63fab25e35703454d65b6afc71dbc65f20041cb46c6d91709141ef7f", size = 124461, upload-time = "2025-08-12T15:12:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/75c6a596ff8df9e4a5894813ff56695f0a218e6ea99420b4a645c4f7795d/orjson-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:7c8ac5f6b682d3494217085cf04dadae66efee45349ad4ee2a1da3c97e2305a8", size = 119494, upload-time = "2025-08-12T15:12:03.337Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3d/9e74742fc261c5ca473c96bb3344d03995869e1dc6402772c60afb97736a/orjson-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:21cf261e8e79284242e4cb1e5924df16ae28255184aafeff19be1405f6d33f67", size = 114046, upload-time = "2025-08-12T15:12:04.87Z" }, + { url = "https://files.pythonhosted.org/packages/4f/08/8ebc6dcac0938376b7e61dff432c33958505ae4c185dda3fa1e6f46ac40b/orjson-3.11.2-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:957f10c7b5bce3d3f2ad577f3b307c784f5dabafcce3b836229c269c11841c86", size = 226498, upload-time = "2025-08-12T15:12:06.51Z" }, + { url = "https://files.pythonhosted.org/packages/ff/74/a97c8e2bc75a27dfeeb1b289645053f1889125447f3b7484a2e34ac55d2a/orjson-3.11.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a669e31ab8eb466c9142ac7a4be2bb2758ad236a31ef40dcd4cf8774ab40f33", size = 111529, upload-time = "2025-08-12T15:12:08.21Z" }, + { url = "https://files.pythonhosted.org/packages/78/c3/55121b5722a1a4e4610a411866cfeada5314dc498cd42435b590353009d2/orjson-3.11.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adedf7d887416c51ad49de3c53b111887e0b63db36c6eb9f846a8430952303d8", size = 116213, upload-time = "2025-08-12T15:12:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/54/d3/1c810fa36a749157f1ec68f825b09d5b6958ed5eaf66c7b89bc0f1656517/orjson-3.11.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ad8873979659ad98fc56377b9c5b93eb8059bf01e6412f7abf7dbb3d637a991", size = 118594, upload-time = "2025-08-12T15:12:11.363Z" }, + { url = "https://files.pythonhosted.org/packages/09/9c/052a6619857aba27899246c1ac9e1566fe976dbb48c2d2d177eb269e6d92/orjson-3.11.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9482ef83b2bf796157566dd2d2742a8a1e377045fe6065fa67acb1cb1d21d9a3", size = 120706, upload-time = "2025-08-12T15:12:13.265Z" }, + { url = "https://files.pythonhosted.org/packages/4b/91/ed0632b8bafa5534d40483ca14f4b7b7e8f27a016f52ff771420b3591574/orjson-3.11.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73cee7867c1fcbd1cc5b6688b3e13db067f968889242955780123a68b3d03316", size = 123412, upload-time = "2025-08-12T15:12:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/90/3d/058184ae52a2035098939329f8864c5e28c3bbd660f80d4f687f4fd3e629/orjson-3.11.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:465166773265f3cc25db10199f5d11c81898a309e26a2481acf33ddbec433fda", size = 121011, upload-time = "2025-08-12T15:12:16.352Z" }, + { url = "https://files.pythonhosted.org/packages/57/ab/70e7a2c26a29878ad81ac551f3d11e184efafeed92c2ea15301ac71e2b44/orjson-3.11.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc000190a7b1d2d8e36cba990b3209a1e15c0efb6c7750e87f8bead01afc0d46", size = 119387, upload-time = "2025-08-12T15:12:17.88Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f1/532be344579590c2faa3d9926ec446e8e030d6d04359a8d6f9b3f4d18283/orjson-3.11.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:df3fdd8efa842ccbb81135d6f58a73512f11dba02ed08d9466261c2e9417af4e", size = 392280, upload-time = "2025-08-12T15:12:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/eb/90/dfb90d82ee7447ba0c5315b1012f36336d34a4b468f5896092926eb2921b/orjson-3.11.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3dacfc621be3079ec69e0d4cb32e3764067726e0ef5a5576428f68b6dc85b4f6", size = 134127, upload-time = "2025-08-12T15:12:22.053Z" }, + { url = "https://files.pythonhosted.org/packages/17/cb/d113d03dfaee4933b0f6e0f3d358886db1468302bb74f1f3c59d9229ce12/orjson-3.11.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9fdff73a029cde5f4a1cf5ec9dbc6acab98c9ddd69f5580c2b3f02ce43ba9f9f", size = 123722, upload-time = "2025-08-12T15:12:23.642Z" }, + { url = "https://files.pythonhosted.org/packages/55/78/a89748f500d7cf909fe0b30093ab87d256c279106048e985269a5530c0a1/orjson-3.11.2-cp39-cp39-win32.whl", hash = "sha256:b1efbdc479c6451138c3733e415b4d0e16526644e54e2f3689f699c4cda303bf", size = 124391, upload-time = "2025-08-12T15:12:25.143Z" }, + { url = "https://files.pythonhosted.org/packages/e8/50/e436f1356650cf96ff62c386dbfeb9ef8dd9cd30c4296103244e7fae2d15/orjson-3.11.2-cp39-cp39-win_amd64.whl", hash = "sha256:c9ec0cc0d4308cad1e38a1ee23b64567e2ff364c2a3fe3d6cbc69cf911c45712", size = 119547, upload-time = "2025-08-12T15:12:26.77Z" }, ] [[package]] @@ -769,7 +769,7 @@ wheels = [ [[package]] name = "pandas" -version = "2.3.1" +version = "2.3.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", @@ -785,49 +785,49 @@ dependencies = [ { name = "pytz", marker = "python_full_version >= '3.9'" }, { name = "tzdata", marker = "python_full_version >= '3.9'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/6f/75aa71f8a14267117adeeed5d21b204770189c0a0025acbdc03c337b28fc/pandas-2.3.1.tar.gz", hash = "sha256:0a95b9ac964fe83ce317827f80304d37388ea77616b1425f0ae41c9d2d0d7bb2", size = 4487493, upload-time = "2025-07-07T19:20:04.079Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ca/aa97b47287221fa37a49634532e520300088e290b20d690b21ce3e448143/pandas-2.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:22c2e866f7209ebc3a8f08d75766566aae02bcc91d196935a1d9e59c7b990ac9", size = 11542731, upload-time = "2025-07-07T19:18:12.619Z" }, - { url = "https://files.pythonhosted.org/packages/80/bf/7938dddc5f01e18e573dcfb0f1b8c9357d9b5fa6ffdee6e605b92efbdff2/pandas-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3583d348546201aff730c8c47e49bc159833f971c2899d6097bce68b9112a4f1", size = 10790031, upload-time = "2025-07-07T19:18:16.611Z" }, - { url = "https://files.pythonhosted.org/packages/ee/2f/9af748366763b2a494fed477f88051dbf06f56053d5c00eba652697e3f94/pandas-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f951fbb702dacd390561e0ea45cdd8ecfa7fb56935eb3dd78e306c19104b9b0", size = 11724083, upload-time = "2025-07-07T19:18:20.512Z" }, - { url = "https://files.pythonhosted.org/packages/2c/95/79ab37aa4c25d1e7df953dde407bb9c3e4ae47d154bc0dd1692f3a6dcf8c/pandas-2.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd05b72ec02ebfb993569b4931b2e16fbb4d6ad6ce80224a3ee838387d83a191", size = 12342360, upload-time = "2025-07-07T19:18:23.194Z" }, - { url = "https://files.pythonhosted.org/packages/75/a7/d65e5d8665c12c3c6ff5edd9709d5836ec9b6f80071b7f4a718c6106e86e/pandas-2.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1b916a627919a247d865aed068eb65eb91a344b13f5b57ab9f610b7716c92de1", size = 13202098, upload-time = "2025-07-07T19:18:25.558Z" }, - { url = "https://files.pythonhosted.org/packages/65/f3/4c1dbd754dbaa79dbf8b537800cb2fa1a6e534764fef50ab1f7533226c5c/pandas-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fe67dc676818c186d5a3d5425250e40f179c2a89145df477dd82945eaea89e97", size = 13837228, upload-time = "2025-07-07T19:18:28.344Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d6/d7f5777162aa9b48ec3910bca5a58c9b5927cfd9cfde3aa64322f5ba4b9f/pandas-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:2eb789ae0274672acbd3c575b0598d213345660120a257b47b5dafdc618aec83", size = 11336561, upload-time = "2025-07-07T19:18:31.211Z" }, - { url = "https://files.pythonhosted.org/packages/76/1c/ccf70029e927e473a4476c00e0d5b32e623bff27f0402d0a92b7fc29bb9f/pandas-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b0540963d83431f5ce8870ea02a7430adca100cec8a050f0811f8e31035541b", size = 11566608, upload-time = "2025-07-07T19:18:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/3c37cb724d76a841f14b8f5fe57e5e3645207cc67370e4f84717e8bb7657/pandas-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fe7317f578c6a153912bd2292f02e40c1d8f253e93c599e82620c7f69755c74f", size = 10823181, upload-time = "2025-07-07T19:18:36.151Z" }, - { url = "https://files.pythonhosted.org/packages/8a/4c/367c98854a1251940edf54a4df0826dcacfb987f9068abf3e3064081a382/pandas-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6723a27ad7b244c0c79d8e7007092d7c8f0f11305770e2f4cd778b3ad5f9f85", size = 11793570, upload-time = "2025-07-07T19:18:38.385Z" }, - { url = "https://files.pythonhosted.org/packages/07/5f/63760ff107bcf5146eee41b38b3985f9055e710a72fdd637b791dea3495c/pandas-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3462c3735fe19f2638f2c3a40bd94ec2dc5ba13abbb032dd2fa1f540a075509d", size = 12378887, upload-time = "2025-07-07T19:18:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/15/53/f31a9b4dfe73fe4711c3a609bd8e60238022f48eacedc257cd13ae9327a7/pandas-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:98bcc8b5bf7afed22cc753a28bc4d9e26e078e777066bc53fac7904ddef9a678", size = 13230957, upload-time = "2025-07-07T19:18:44.187Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/6fce6bf85b5056d065e0a7933cba2616dcb48596f7ba3c6341ec4bcc529d/pandas-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d544806b485ddf29e52d75b1f559142514e60ef58a832f74fb38e48d757b299", size = 13883883, upload-time = "2025-07-07T19:18:46.498Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/bdcb1ed8fccb63d04bdb7635161d0ec26596d92c9d7a6cce964e7876b6c1/pandas-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b3cd4273d3cb3707b6fffd217204c52ed92859533e31dc03b7c5008aa933aaab", size = 11340212, upload-time = "2025-07-07T19:18:49.293Z" }, - { url = "https://files.pythonhosted.org/packages/46/de/b8445e0f5d217a99fe0eeb2f4988070908979bec3587c0633e5428ab596c/pandas-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:689968e841136f9e542020698ee1c4fbe9caa2ed2213ae2388dc7b81721510d3", size = 11588172, upload-time = "2025-07-07T19:18:52.054Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e0/801cdb3564e65a5ac041ab99ea6f1d802a6c325bb6e58c79c06a3f1cd010/pandas-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:025e92411c16cbe5bb2a4abc99732a6b132f439b8aab23a59fa593eb00704232", size = 10717365, upload-time = "2025-07-07T19:18:54.785Z" }, - { url = "https://files.pythonhosted.org/packages/51/a5/c76a8311833c24ae61a376dbf360eb1b1c9247a5d9c1e8b356563b31b80c/pandas-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b7ff55f31c4fcb3e316e8f7fa194566b286d6ac430afec0d461163312c5841e", size = 11280411, upload-time = "2025-07-07T19:18:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/da/01/e383018feba0a1ead6cf5fe8728e5d767fee02f06a3d800e82c489e5daaf/pandas-2.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dcb79bf373a47d2a40cf7232928eb7540155abbc460925c2c96d2d30b006eb4", size = 11988013, upload-time = "2025-07-07T19:18:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/5b/14/cec7760d7c9507f11c97d64f29022e12a6cc4fc03ac694535e89f88ad2ec/pandas-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56a342b231e8862c96bdb6ab97170e203ce511f4d0429589c8ede1ee8ece48b8", size = 12767210, upload-time = "2025-07-07T19:19:02.944Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/6e2d2c6728ed29fb3d4d4d302504fb66f1a543e37eb2e43f352a86365cdf/pandas-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ca7ed14832bce68baef331f4d7f294411bed8efd032f8109d690df45e00c4679", size = 13440571, upload-time = "2025-07-07T19:19:06.82Z" }, - { url = "https://files.pythonhosted.org/packages/80/a5/3a92893e7399a691bad7664d977cb5e7c81cf666c81f89ea76ba2bff483d/pandas-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ac942bfd0aca577bef61f2bc8da8147c4ef6879965ef883d8e8d5d2dc3e744b8", size = 10987601, upload-time = "2025-07-07T19:19:09.589Z" }, - { url = "https://files.pythonhosted.org/packages/32/ed/ff0a67a2c5505e1854e6715586ac6693dd860fbf52ef9f81edee200266e7/pandas-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9026bd4a80108fac2239294a15ef9003c4ee191a0f64b90f170b40cfb7cf2d22", size = 11531393, upload-time = "2025-07-07T19:19:12.245Z" }, - { url = "https://files.pythonhosted.org/packages/c7/db/d8f24a7cc9fb0972adab0cc80b6817e8bef888cfd0024eeb5a21c0bb5c4a/pandas-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6de8547d4fdb12421e2d047a2c446c623ff4c11f47fddb6b9169eb98ffba485a", size = 10668750, upload-time = "2025-07-07T19:19:14.612Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b0/80f6ec783313f1e2356b28b4fd8d2148c378370045da918c73145e6aab50/pandas-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782647ddc63c83133b2506912cc6b108140a38a37292102aaa19c81c83db2928", size = 11342004, upload-time = "2025-07-07T19:19:16.857Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e2/20a317688435470872885e7fc8f95109ae9683dec7c50be29b56911515a5/pandas-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba6aff74075311fc88504b1db890187a3cd0f887a5b10f5525f8e2ef55bfdb9", size = 12050869, upload-time = "2025-07-07T19:19:19.265Z" }, - { url = "https://files.pythonhosted.org/packages/55/79/20d746b0a96c67203a5bee5fb4e00ac49c3e8009a39e1f78de264ecc5729/pandas-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5635178b387bd2ba4ac040f82bc2ef6e6b500483975c4ebacd34bec945fda12", size = 12750218, upload-time = "2025-07-07T19:19:21.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0f/145c8b41e48dbf03dd18fdd7f24f8ba95b8254a97a3379048378f33e7838/pandas-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f3bf5ec947526106399a9e1d26d40ee2b259c66422efdf4de63c848492d91bb", size = 13416763, upload-time = "2025-07-07T19:19:23.939Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c0/54415af59db5cdd86a3d3bf79863e8cc3fa9ed265f0745254061ac09d5f2/pandas-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:1c78cf43c8fde236342a1cb2c34bcff89564a7bfed7e474ed2fffa6aed03a956", size = 10987482, upload-time = "2025-07-07T19:19:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/2fd2e400073a1230e13b8cd604c9bc95d9e3b962e5d44088ead2e8f0cfec/pandas-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8dfc17328e8da77be3cf9f47509e5637ba8f137148ed0e9b5241e1baf526e20a", size = 12029159, upload-time = "2025-07-07T19:19:26.362Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0a/d84fd79b0293b7ef88c760d7dca69828d867c89b6d9bc52d6a27e4d87316/pandas-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ec6c851509364c59a5344458ab935e6451b31b818be467eb24b0fe89bd05b6b9", size = 11393287, upload-time = "2025-07-07T19:19:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/50/ae/ff885d2b6e88f3c7520bb74ba319268b42f05d7e583b5dded9837da2723f/pandas-2.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:911580460fc4884d9b05254b38a6bfadddfcc6aaef856fb5859e7ca202e45275", size = 11309381, upload-time = "2025-07-07T19:19:31.436Z" }, - { url = "https://files.pythonhosted.org/packages/85/86/1fa345fc17caf5d7780d2699985c03dbe186c68fee00b526813939062bb0/pandas-2.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f4d6feeba91744872a600e6edbbd5b033005b431d5ae8379abee5bcfa479fab", size = 11883998, upload-time = "2025-07-07T19:19:34.267Z" }, - { url = "https://files.pythonhosted.org/packages/81/aa/e58541a49b5e6310d89474333e994ee57fea97c8aaa8fc7f00b873059bbf/pandas-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fe37e757f462d31a9cd7580236a82f353f5713a80e059a29753cf938c6775d96", size = 12704705, upload-time = "2025-07-07T19:19:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f9/07086f5b0f2a19872554abeea7658200824f5835c58a106fa8f2ae96a46c/pandas-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5db9637dbc24b631ff3707269ae4559bce4b7fd75c1c4d7e13f40edc42df4444", size = 13189044, upload-time = "2025-07-07T19:19:39.999Z" }, - { url = "https://files.pythonhosted.org/packages/6e/21/ecf2df680982616459409b09962a8c2065330c7151dc6538069f3b634acf/pandas-2.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4645f770f98d656f11c69e81aeb21c6fca076a44bed3dcbb9396a4311bc7f6d8", size = 11567275, upload-time = "2025-07-07T19:19:45.152Z" }, - { url = "https://files.pythonhosted.org/packages/1e/1a/dcb50e44b75419e96b276c9fb023b0f147b3c411be1cd517492aa2a184d4/pandas-2.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:342e59589cc454aaff7484d75b816a433350b3d7964d7847327edda4d532a2e3", size = 10811488, upload-time = "2025-07-07T19:19:47.797Z" }, - { url = "https://files.pythonhosted.org/packages/2d/55/66cd2b679f6a27398380eac7574bc24746128f74626a3c02b978ea00e5ce/pandas-2.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d12f618d80379fde6af007f65f0c25bd3e40251dbd1636480dfffce2cf1e6da", size = 11763000, upload-time = "2025-07-07T19:19:50.83Z" }, - { url = "https://files.pythonhosted.org/packages/ae/1c/5b9b263c80fd5e231b77df6f78cd7426d1d4ad3a4e858e85b7b3d93d0e9c/pandas-2.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd71c47a911da120d72ef173aeac0bf5241423f9bfea57320110a978457e069e", size = 12361395, upload-time = "2025-07-07T19:19:53.714Z" }, - { url = "https://files.pythonhosted.org/packages/f7/74/7e817b31413fbb96366ea327d43d1926a9c48c58074e27e094e2839a0e36/pandas-2.3.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:09e3b1587f0f3b0913e21e8b32c3119174551deb4a4eba4a89bc7377947977e7", size = 13225086, upload-time = "2025-07-07T19:19:56.378Z" }, - { url = "https://files.pythonhosted.org/packages/1f/0f/bc0a44b47eba2f22ae4235719a573d552ef7ad76ed3ea39ae62d554e040b/pandas-2.3.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2323294c73ed50f612f67e2bf3ae45aea04dce5690778e08a09391897f35ff88", size = 13871698, upload-time = "2025-07-07T19:19:58.854Z" }, - { url = "https://files.pythonhosted.org/packages/fa/cb/6c32f8fadefa4314b740fbe8f74f6a02423bd1549e7c930826df35ac3c1b/pandas-2.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4b0de34dc8499c2db34000ef8baad684cfa4cbd836ecee05f323ebfba348c7d", size = 11357186, upload-time = "2025-07-07T19:20:01.475Z" }, + { url = "https://files.pythonhosted.org/packages/2e/16/a8eeb70aad84ccbf14076793f90e0031eded63c1899aeae9fdfbf37881f4/pandas-2.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:52bc29a946304c360561974c6542d1dd628ddafa69134a7131fdfd6a5d7a1a35", size = 11539648, upload-time = "2025-08-21T10:26:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/c5bdaea13bf3708554d93e948b7ea74121ce6e0d59537ca4c4f77731072b/pandas-2.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:220cc5c35ffaa764dd5bb17cf42df283b5cb7fdf49e10a7b053a06c9cb48ee2b", size = 10786923, upload-time = "2025-08-21T10:26:40.518Z" }, + { url = "https://files.pythonhosted.org/packages/bb/10/811fa01476d29ffed692e735825516ad0e56d925961819e6126b4ba32147/pandas-2.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c05e15111221384019897df20c6fe893b2f697d03c811ee67ec9e0bb5a3424", size = 11726241, upload-time = "2025-08-21T10:26:43.175Z" }, + { url = "https://files.pythonhosted.org/packages/c4/6a/40b043b06e08df1ea1b6d20f0e0c2f2c4ec8c4f07d1c92948273d943a50b/pandas-2.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc03acc273c5515ab69f898df99d9d4f12c4d70dbfc24c3acc6203751d0804cf", size = 12349533, upload-time = "2025-08-21T10:26:46.611Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/2e081a2302e41a9bca7056659fdd2b85ef94923723e41665b42d65afd347/pandas-2.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d25c20a03e8870f6339bcf67281b946bd20b86f1a544ebbebb87e66a8d642cba", size = 13202407, upload-time = "2025-08-21T10:26:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f4/12/7ff9f6a79e2ee8869dcf70741ef998b97ea20050fe25f83dc759764c1e32/pandas-2.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21bb612d148bb5860b7eb2c10faacf1a810799245afd342cf297d7551513fbb6", size = 13837212, upload-time = "2025-08-21T10:26:51.832Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/5ab92fcd76455a632b3db34a746e1074d432c0cdbbd28d7cd1daba46a75d/pandas-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b62d586eb25cb8cb70a5746a378fc3194cb7f11ea77170d59f889f5dfe3cec7a", size = 11338099, upload-time = "2025-08-21T10:26:54.382Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/f3e010879f118c2d400902d2d871c2226cef29b08c09fb8dc41111730400/pandas-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1333e9c299adcbb68ee89a9bb568fc3f20f9cbb419f1dd5225071e6cddb2a743", size = 11563308, upload-time = "2025-08-21T10:26:56.656Z" }, + { url = "https://files.pythonhosted.org/packages/38/18/48f10f1cc5c397af59571d638d211f494dba481f449c19adbd282aa8f4ca/pandas-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:76972bcbd7de8e91ad5f0ca884a9f2c477a2125354af624e022c49e5bd0dfff4", size = 10820319, upload-time = "2025-08-21T10:26:59.162Z" }, + { url = "https://files.pythonhosted.org/packages/95/3b/1e9b69632898b048e223834cd9702052bcf06b15e1ae716eda3196fb972e/pandas-2.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98bdd7c456a05eef7cd21fd6b29e3ca243591fe531c62be94a2cc987efb5ac2", size = 11790097, upload-time = "2025-08-21T10:27:02.204Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/0e2ffb30b1f7fbc9a588bd01e3c14a0d96854d09a887e15e30cc19961227/pandas-2.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d81573b3f7db40d020983f78721e9bfc425f411e616ef019a10ebf597aedb2e", size = 12397958, upload-time = "2025-08-21T10:27:05.409Z" }, + { url = "https://files.pythonhosted.org/packages/23/82/e6b85f0d92e9afb0e7f705a51d1399b79c7380c19687bfbf3d2837743249/pandas-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e190b738675a73b581736cc8ec71ae113d6c3768d0bd18bffa5b9a0927b0b6ea", size = 13225600, upload-time = "2025-08-21T10:27:07.791Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f1/f682015893d9ed51611948bd83683670842286a8edd4f68c2c1c3b231eef/pandas-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c253828cb08f47488d60f43c5fc95114c771bbfff085da54bfc79cb4f9e3a372", size = 13879433, upload-time = "2025-08-21T10:27:10.347Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/ae86261695b6c8a36d6a4c8d5f9b9ede8248510d689a2f379a18354b37d7/pandas-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9467697b8083f9667b212633ad6aa4ab32436dcbaf4cd57325debb0ddef2012f", size = 11336557, upload-time = "2025-08-21T10:27:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" }, + { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" }, + { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" }, + { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" }, + { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" }, + { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" }, + { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" }, + { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" }, + { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" }, + { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" }, + { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/b37e090d0aceda9b4dd85c8dbd1bea65b1de9e7a4f690d6bd3a40bd16390/pandas-2.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:88080a0ff8a55eac9c84e3ff3c7665b3b5476c6fbc484775ca1910ce1c3e0b87", size = 11551511, upload-time = "2025-08-21T10:28:11.111Z" }, + { url = "https://files.pythonhosted.org/packages/b9/47/381fb1e7adcfcf4230fa6dc3a741acbac6c6fe072f19f4e7a46bddf3e5f6/pandas-2.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d4a558c7620340a0931828d8065688b3cc5b4c8eb674bcaf33d18ff4a6870b4a", size = 10797930, upload-time = "2025-08-21T10:28:13.436Z" }, + { url = "https://files.pythonhosted.org/packages/36/ca/d42467829080b92fc46d451288af8068f129fbcfb6578d573f45120de5cf/pandas-2.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45178cf09d1858a1509dc73ec261bf5b25a625a389b65be2e47b559905f0ab6a", size = 11738470, upload-time = "2025-08-21T10:28:16.065Z" }, + { url = "https://files.pythonhosted.org/packages/60/76/7d0f0a0deed7867c51163982d7b79c0a089096cd7ad50e1b87c2c82220e9/pandas-2.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77cefe00e1b210f9c76c697fedd8fdb8d3dd86563e9c8adc9fa72b90f5e9e4c2", size = 12366640, upload-time = "2025-08-21T10:28:18.557Z" }, + { url = "https://files.pythonhosted.org/packages/21/31/56784743e421cf51e34358fe7e5954345e5942168897bf8eb5707b71eedb/pandas-2.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:13bd629c653856f00c53dc495191baa59bcafbbf54860a46ecc50d3a88421a96", size = 13211567, upload-time = "2025-08-21T10:28:20.998Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4e/50a399dc7d9dd4aa09a03b163751d428026cf0f16c419b4010f6aca26ebd/pandas-2.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:36d627906fd44b5fd63c943264e11e96e923f8de77d6016dc2f667b9ad193438", size = 13854073, upload-time = "2025-08-21T10:28:24.056Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/8978a84861a5124e56ce1048376569545412501fcb9a83f035393d6d85bc/pandas-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:a9d7ec92d71a420185dec44909c32e9a362248c4ae2238234b76d5be37f208cc", size = 11346452, upload-time = "2025-08-21T10:28:26.691Z" }, ] [[package]] @@ -850,16 +850,16 @@ wheels = [ [[package]] name = "plotly" -version = "6.2.0" +version = "6.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "narwhals", version = "1.42.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "narwhals", version = "2.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "narwhals", version = "2.1.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/0efc297df362b88b74957a230af61cd6929f531f72f48063e8408702ffba/plotly-6.2.0.tar.gz", hash = "sha256:9dfa23c328000f16c928beb68927444c1ab9eae837d1fe648dbcda5360c7953d", size = 6801941, upload-time = "2025-06-26T16:20:45.765Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/64/850de5076f4436410e1ce4f6a69f4313ef6215dfea155f3f6559335cad29/plotly-6.3.0.tar.gz", hash = "sha256:8840a184d18ccae0f9189c2b9a2943923fd5cae7717b723f36eef78f444e5a73", size = 6923926, upload-time = "2025-08-12T20:22:14.127Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ed/20/f2b7ac96a91cc5f70d81320adad24cc41bf52013508d649b1481db225780/plotly-6.2.0-py3-none-any.whl", hash = "sha256:32c444d4c940887219cb80738317040363deefdfee4f354498cc0b6dab8978bd", size = 9635469, upload-time = "2025-06-26T16:20:40.76Z" }, + { url = "https://files.pythonhosted.org/packages/95/a9/12e2dc726ba1ba775a2c6922d5d5b4488ad60bdab0888c337c194c8e6de8/plotly-6.3.0-py3-none-any.whl", hash = "sha256:7ad806edce9d3cdd882eaebaf97c0c9e252043ed1ed3d382c3e3520ec07806d4", size = 9791257, upload-time = "2025-08-12T20:22:09.205Z" }, ] [package.optional-dependencies] @@ -916,7 +916,7 @@ wheels = [ [[package]] name = "poethepoet" -version = "0.36.0" +version = "0.37.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12'", @@ -929,9 +929,9 @@ dependencies = [ { name = "pyyaml", marker = "python_full_version >= '3.9'" }, { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/ac/311c8a492dc887f0b7a54d0ec3324cb2f9538b7b78ea06e5f7ae1f167e52/poethepoet-0.36.0.tar.gz", hash = "sha256:2217b49cb4e4c64af0b42ff8c4814b17f02e107d38bc461542517348ede25663", size = 66854, upload-time = "2025-06-29T19:54:50.444Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f2/273fe54a78dc5c6c8dd63db71f5a6ceb95e4648516b5aeaeff4bde804e44/poethepoet-0.37.0.tar.gz", hash = "sha256:73edf458707c674a079baa46802e21455bda3a7f82a408e58c31b9f4fe8e933d", size = 68570, upload-time = "2025-08-11T18:00:29.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/29/dedb3a6b7e17ea723143b834a2da428a7d743c80d5cd4d22ed28b5e8c441/poethepoet-0.36.0-py3-none-any.whl", hash = "sha256:693e3c1eae9f6731d3613c3c0c40f747d3c5c68a375beda42e590a63c5623308", size = 88031, upload-time = "2025-06-29T19:54:48.884Z" }, + { url = "https://files.pythonhosted.org/packages/92/1b/5337af1a6a478d25a3e3c56b9b4b42b0a160314e02f4a0498d5322c8dac4/poethepoet-0.37.0-py3-none-any.whl", hash = "sha256:861790276315abcc8df1b4bd60e28c3d48a06db273edd3092f3c94e1a46e5e22", size = 90062, upload-time = "2025-08-11T18:00:27.595Z" }, ] [[package]] From d36dbab39fef2a83edf4a57f47ec3e2f993cdc11 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 15:41:15 -0500 Subject: [PATCH 008/167] Add types to __init__ --- src/py/kaleido/__init__.py | 48 ++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/src/py/kaleido/__init__.py b/src/py/kaleido/__init__.py index d9961019..12047e04 100644 --- a/src/py/kaleido/__init__.py +++ b/src/py/kaleido/__init__.py @@ -6,12 +6,24 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from choreographer.cli import get_chrome, get_chrome_sync from . import _sync_server from ._page_generator import PageGenerator from .kaleido import Kaleido +if TYPE_CHECKING: + from collections.abc import AsyncIterable, Iterable, TypeVar + from pathlib import Path + from typing import Any + + from ._fig_tools import Figurish, LayoutOpts + + T = TypeVar("T") + AnyIterable = AsyncIterable[T] | Iterable[T] + __all__ = [ "Kaleido", "PageGenerator", @@ -30,7 +42,7 @@ _global_server = _sync_server.GlobalKaleidoServer() -def start_sync_server(*args, silence_warnings=False, **kwargs): +def start_sync_server(*args: Any, silence_warnings: bool = False, **kwargs: Any): """ Start a kaleido server which will process all sync generation requests. @@ -50,7 +62,7 @@ def start_sync_server(*args, silence_warnings=False, **kwargs): _global_server.open(*args, silence_warnings=silence_warnings, **kwargs) -def stop_sync_server(*, silence_warnings=False): +def stop_sync_server(*, silence_warnings: bool = False): """ Stop the kaleido server. It can be restarted. Warns if not started. @@ -63,12 +75,12 @@ def stop_sync_server(*, silence_warnings=False): async def calc_fig( - fig, - path=None, - opts=None, + fig: Figurish, + path: str | None | Path = None, + opts: LayoutOpts | None = None, *, - topojson=None, - kopts=None, + topojson: str | None = None, + kopts: dict[str, Any] | None = None, ): """ Return binary for plotly figure. @@ -85,7 +97,7 @@ async def calc_fig( """ kopts = kopts or {} - kopts["n"] = 1 + kopts["n"] = 1 # should we force this? async with Kaleido(**kopts) as k: return await k.calc_fig( fig, @@ -96,14 +108,14 @@ async def calc_fig( async def write_fig( # noqa: PLR0913 (too many args, complexity) - fig, - path=None, - opts=None, + fig: Figurish, + path: str | None | Path = None, + opts: LayoutOpts | None = None, *, - topojson=None, + topojson: str | None = None, + kopts: dict[str, Any] | None = None, error_log=None, profiler=None, - kopts=None, ): """ Write a plotly figure(s) to a file. @@ -129,11 +141,11 @@ async def write_fig( # noqa: PLR0913 (too many args, complexity) async def write_fig_from_object( - generator, + generator: AnyIterable, # this could be more specific with [] *, + kopts: dict[str, Any] | None = None, error_log=None, profiler=None, - kopts=None, ): """ Write a plotly figure(s) to a file. @@ -155,7 +167,7 @@ async def write_fig_from_object( ) -def calc_fig_sync(*args, **kwargs): +def calc_fig_sync(*args: Any, **kwargs: Any): """Call `calc_fig` but blocking.""" if _global_server.is_running(): return _global_server.call_function("calc_fig", *args, **kwargs) @@ -163,7 +175,7 @@ def calc_fig_sync(*args, **kwargs): return _sync_server.oneshot_async_run(calc_fig, args=args, kwargs=kwargs) -def write_fig_sync(*args, **kwargs): +def write_fig_sync(*args: Any, **kwargs: Any): """Call `write_fig` but blocking.""" if _global_server.is_running(): _global_server.call_function("write_fig", *args, **kwargs) @@ -171,7 +183,7 @@ def write_fig_sync(*args, **kwargs): _sync_server.oneshot_async_run(write_fig, args=args, kwargs=kwargs) -def write_fig_from_object_sync(*args, **kwargs): +def write_fig_from_object_sync(*args: Any, **kwargs: Any): """Call `write_fig_from_object` but blocking.""" if _global_server.is_running(): _global_server.call_function("write_fig_from_object", *args, **kwargs) From 9838ac4c889a08cb25109177c0af8d6c56cb39dc Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 15:49:51 -0500 Subject: [PATCH 009/167] Fix some bad typing in __init__.py --- src/py/kaleido/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/kaleido/__init__.py b/src/py/kaleido/__init__.py index 12047e04..2591ae1f 100644 --- a/src/py/kaleido/__init__.py +++ b/src/py/kaleido/__init__.py @@ -15,14 +15,14 @@ from .kaleido import Kaleido if TYPE_CHECKING: - from collections.abc import AsyncIterable, Iterable, TypeVar + from collections.abc import AsyncIterable, Iterable from pathlib import Path - from typing import Any + from typing import Any, TypeVar, Union from ._fig_tools import Figurish, LayoutOpts T = TypeVar("T") - AnyIterable = AsyncIterable[T] | Iterable[T] + AnyIterable = Union[AsyncIterable[T], Iterable[T]] __all__ = [ "Kaleido", From 7d4d2973035eddbfaa9f9e0df92f63f40458328b Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 16:34:36 -0500 Subject: [PATCH 010/167] Improve page logic. --- src/py/kaleido/kaleido.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 72c6c901..85218761 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -4,10 +4,11 @@ import asyncio import warnings -from collections.abc import AsyncIterable, Iterable +from collections.abc import Iterable from functools import partial from pathlib import Path from typing import TYPE_CHECKING +from urllib.parse import unquote, urlparse import choreographer as choreo import logistro @@ -155,10 +156,18 @@ def __init__( # noqa: D417, PLR0913 no args/kwargs in description "or `kaleido.get_chrome_sync()`.", ) from ChromeNotFoundError - if page and isinstance(page, str) and Path(page).is_file(): - self._index = page - elif page and hasattr(page, "is_file") and page.is_file(): - self._index = page.as_uri() + if isinstance(page, str): + if page.startswith(r"file://") and Path(unquote(urlparse(page).path)): + self._index = page + elif Path(page).is_file(): + self._index = Path(page).as_uri() + else: + raise FileNotFoundError(f"{page} does not exist.") + elif isinstance(page, Path): + if page.is_file(): + self._index = page.as_uri() + else: + raise FileNotFoundError(f"{page!s} does not exist.") else: self._tmp_dir = TmpDirectory(sneak=self.is_isolated()) index = self._tmp_dir.path / "index.html" From cbd925b8c8fcb3616f891126aa9fc665fcf7f611 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 17:35:34 -0500 Subject: [PATCH 011/167] Add claude run on init and public api --- src/py/tests/test_init.py | 163 +++++++++++++++++++++++++++++++ src/py/tests/test_public_api.py | 168 ++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 src/py/tests/test_init.py create mode 100644 src/py/tests/test_public_api.py diff --git a/src/py/tests/test_init.py b/src/py/tests/test_init.py new file mode 100644 index 00000000..0c2641a0 --- /dev/null +++ b/src/py/tests/test_init.py @@ -0,0 +1,163 @@ +"""Tests for wrapper functions in __init__.py that test argument passing.""" + +from unittest.mock import MagicMock, patch + +import pytest + +import kaleido + + +@pytest.fixture +def mock_kaleido_context(): + """Fixture that provides a mocked Kaleido context manager.""" + mock_kaleido = MagicMock() + mock_kaleido.__aenter__ = MagicMock(return_value=mock_kaleido) + mock_kaleido.__aexit__ = MagicMock(return_value=False) + return mock_kaleido + + +@patch("kaleido._sync_server.GlobalKaleidoServer.open") +def test_start_sync_server_passes_args(mock_open): + """Test that start_sync_server passes args to GlobalKaleidoServer.open.""" + args = ("arg1", "arg2") + kwargs = {"key1": "value1", "key2": "value2"} + + kaleido.start_sync_server(*args, **kwargs) + + mock_open.assert_called_once_with(*args, silence_warnings=False, **kwargs) + + +@patch("kaleido._sync_server.GlobalKaleidoServer.open") +def test_start_sync_server_silence_warnings(mock_open): + """Test that start_sync_server passes silence_warnings parameter correctly.""" + args = ("arg1",) + kwargs = {"key1": "value1"} + + kaleido.start_sync_server(*args, silence_warnings=True, **kwargs) + + mock_open.assert_called_once_with(*args, silence_warnings=True, **kwargs) + + +@patch("kaleido._sync_server.GlobalKaleidoServer.close") +def test_stop_sync_server_passes_args(mock_close): + """Test that stop_sync_server passes silence_warnings to GlobalKaleidoServer.""" + kaleido.stop_sync_server() + + mock_close.assert_called_once_with(silence_warnings=False) + + +@patch("kaleido._sync_server.GlobalKaleidoServer.close") +def test_stop_sync_server_silence_warnings(mock_close): + """Test that stop_sync_server passes silence_warnings=True correctly.""" + kaleido.stop_sync_server(silence_warnings=True) + + mock_close.assert_called_once_with(silence_warnings=True) + + +@patch("kaleido.Kaleido") +@pytest.mark.asyncio +async def test_calc_fig_passes_args_and_forces_n_to_1( + mock_kaleido_class, + mock_kaleido_context, +): + """Test that calc_fig passes args correctly and forces n=1 in kopts.""" + mock_kaleido_context.calc_fig.return_value = b"test_bytes" + mock_kaleido_class.return_value = mock_kaleido_context + + fig = {"data": []} + path = "test.png" + opts = {"width": 800} + topojson = "test_topojson" + kopts = {"some_option": "value"} + + result = await kaleido.calc_fig(fig, path, opts, topojson=topojson, kopts=kopts) + + # Check that Kaleido was instantiated with kopts including n=1 + expected_kopts = {"some_option": "value", "n": 1} + mock_kaleido_class.assert_called_once_with(**expected_kopts) + + # Check that calc_fig was called with correct arguments + mock_kaleido_context.calc_fig.assert_called_once_with( + fig, + path=path, + opts=opts, + topojson=topojson, + ) + + assert result == b"test_bytes" + + +@patch("kaleido.Kaleido") +@pytest.mark.asyncio +async def test_calc_fig_empty_kopts(mock_kaleido_class, mock_kaleido_context): + """Test that calc_fig works with empty kopts.""" + mock_kaleido_context.calc_fig.return_value = b"test_bytes" + mock_kaleido_class.return_value = mock_kaleido_context + + fig = {"data": []} + + await kaleido.calc_fig(fig) + + # Check that Kaleido was instantiated with only n=1 + mock_kaleido_class.assert_called_once_with(n=1) + + +@patch("kaleido.Kaleido") +@pytest.mark.asyncio +async def test_write_fig_passes_args(mock_kaleido_class, mock_kaleido_context): + """Test that write_fig passes all arguments correctly.""" + mock_kaleido_class.return_value = mock_kaleido_context + + fig = {"data": []} + path = "test.png" + opts = {"width": 800} + topojson = "test_topojson" + kopts = {"some_option": "value"} + + await kaleido.write_fig(fig, path, opts, topojson=topojson, kopts=kopts) + + # Check that Kaleido was instantiated with kopts + mock_kaleido_class.assert_called_once_with(**kopts) + + # Check that write_fig was called with correct arguments + mock_kaleido_context.write_fig.assert_called_once_with( + fig, + path=path, + opts=opts, + topojson=topojson, + ) + + +@patch("kaleido.Kaleido") +@pytest.mark.asyncio +async def test_write_fig_empty_kopts(mock_kaleido_class, mock_kaleido_context): + """Test that write_fig works with empty kopts.""" + mock_kaleido_class.return_value = mock_kaleido_context + + fig = {"data": []} + + await kaleido.write_fig(fig) + + # Check that Kaleido was instantiated with empty dict + mock_kaleido_class.assert_called_once_with() + + +@patch("kaleido.Kaleido") +@pytest.mark.asyncio +async def test_write_fig_from_object_passes_args( + mock_kaleido_class, + mock_kaleido_context, +): + """Test that write_fig_from_object passes all arguments correctly.""" + mock_kaleido_class.return_value = mock_kaleido_context + + generator = [{"data": []}] + kopts = {"some_option": "value"} + + await kaleido.write_fig_from_object(generator, kopts=kopts) + + # Check that Kaleido was instantiated with kopts + mock_kaleido_class.assert_called_once_with(**kopts) + + # Check that write_fig_from_object was called with correct arguments + mock_kaleido_context.write_fig_from_object.assert_called_once_with(generator) diff --git a/src/py/tests/test_public_api.py b/src/py/tests/test_public_api.py new file mode 100644 index 00000000..ebd9594b --- /dev/null +++ b/src/py/tests/test_public_api.py @@ -0,0 +1,168 @@ +"""Integrative tests for all public API functions in __init__.py using basic figures.""" + +import warnings +from unittest.mock import patch + +import pytest + +import kaleido + +# allows to create a browser pool for tests +pytestmark = pytest.mark.asyncio(loop_scope="function") + + +@pytest.fixture +def simple_figure(): + """Create a simple plotly figure for testing.""" + # ruff: noqa: PLC0415 + import plotly.express as px + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + fig = px.line(x=[1, 2, 3, 4], y=[1, 2, 3, 4]) + + return fig + + +async def test_calc_fig_basic(simple_figure): + """Test calc_fig with a basic figure.""" + result = await kaleido.calc_fig(simple_figure) + assert isinstance(result, bytes) + assert len(result) > 0 + + +def test_calc_fig_sync_basic_server_running(simple_figure): + """Test calc_fig_sync when sync server is running.""" + with patch("kaleido._global_server.is_running", return_value=True), patch( + "kaleido._global_server.call_function", + return_value=b"test_bytes", + ) as mock_call: + result = kaleido.calc_fig_sync(simple_figure) + + mock_call.assert_called_once_with("calc_fig", simple_figure) + assert result == b"test_bytes" + + +def test_calc_fig_sync_basic_server_not_running(simple_figure): + """Test calc_fig_sync when sync server is not running.""" + with patch("kaleido._global_server.is_running", return_value=False), patch( + "kaleido._sync_server.oneshot_async_run", + return_value=b"test_bytes", + ) as mock_oneshot: + result = kaleido.calc_fig_sync(simple_figure) + + mock_oneshot.assert_called_once_with( + kaleido.calc_fig, + args=(simple_figure,), + kwargs={}, + ) + assert result == b"test_bytes" + + +async def test_write_fig_basic(simple_figure, tmp_path): + """Test write_fig with a basic figure.""" + output_file = tmp_path / "test_output.png" + + await kaleido.write_fig(simple_figure, path=str(output_file)) + + # Check that file was created (actual implementation would create the file) + # For this test we're just ensuring the function runs without error + + +def test_write_fig_sync_basic_server_running(simple_figure): + """Test write_fig_sync when sync server is running.""" + with patch("kaleido._global_server.is_running", return_value=True), patch( + "kaleido._global_server.call_function", + ) as mock_call: + kaleido.write_fig_sync(simple_figure, path="test.png") + + mock_call.assert_called_once_with("write_fig", simple_figure, path="test.png") + + +def test_write_fig_sync_basic_server_not_running(simple_figure): + """Test write_fig_sync when sync server is not running.""" + with patch("kaleido._global_server.is_running", return_value=False), patch( + "kaleido._sync_server.oneshot_async_run", + ) as mock_oneshot: + kaleido.write_fig_sync(simple_figure, path="test.png") + + mock_oneshot.assert_called_once_with( + kaleido.write_fig, + args=(simple_figure,), + kwargs={"path": "test.png"}, + ) + + +async def test_write_fig_from_object_basic(simple_figure): + """Test write_fig_from_object with a basic figure generator.""" + generator = [simple_figure] + + # This should run without error + await kaleido.write_fig_from_object(generator) + + +def test_write_fig_from_object_sync_basic_server_running(simple_figure): + """Test write_fig_from_object_sync when sync server is running.""" + generator = [simple_figure] + + with patch("kaleido._global_server.is_running", return_value=True), patch( + "kaleido._global_server.call_function", + ) as mock_call: + kaleido.write_fig_from_object_sync(generator) + + mock_call.assert_called_once_with("write_fig_from_object", generator) + + +def test_write_fig_from_object_sync_basic_server_not_running(simple_figure): + """Test write_fig_from_object_sync when sync server is not running.""" + generator = [simple_figure] + + with patch("kaleido._global_server.is_running", return_value=False), patch( + "kaleido._sync_server.oneshot_async_run", + ) as mock_oneshot: + kaleido.write_fig_from_object_sync(generator) + + mock_oneshot.assert_called_once_with( + kaleido.write_fig_from_object, + args=(generator,), + kwargs={}, + ) + + +def test_start_stop_sync_server_integration(): + """Test start_sync_server and stop_sync_server together.""" + # Test that we can start and stop the server without errors + kaleido.start_sync_server(silence_warnings=True) + kaleido.stop_sync_server(silence_warnings=True) + + +def test_sync_server_with_calc_fig_sync_integration(simple_figure): + """Integration test: start server, use calc_fig_sync, then stop server.""" + # Start server + kaleido.start_sync_server(silence_warnings=True) + + try: + # Use calc_fig_sync - this should use the running server + result = kaleido.calc_fig_sync(simple_figure) + assert isinstance(result, bytes) + assert len(result) > 0 + + finally: + # Always stop the server + kaleido.stop_sync_server(silence_warnings=True) + + +def test_sync_server_with_write_fig_sync_integration(simple_figure, tmp_path): + """Integration test: start server, use write_fig_sync, then stop server.""" + output_file = tmp_path / "test_integration.png" + + # Start server + kaleido.start_sync_server(silence_warnings=True) + + try: + # Use write_fig_sync - this should use the running server + kaleido.write_fig_sync(simple_figure, path=str(output_file)) + + finally: + # Always stop the server + kaleido.stop_sync_server(silence_warnings=True) From d99bb8801c153b5b8b5c5cbbc721bc381cee2244 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 19:21:10 -0500 Subject: [PATCH 012/167] Move pytest async detection to auto --- src/py/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/pyproject.toml b/src/py/pyproject.toml index 392ddb25..1cca5695 100644 --- a/src/py/pyproject.toml +++ b/src/py/pyproject.toml @@ -85,6 +85,7 @@ ignore = [ ] [tool.pytest.ini_options] +asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" log_cli = false From 6d3cf13bf766267997bbfe5c340de386260149e8 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 20:08:48 -0500 Subject: [PATCH 013/167] Iterate with Claude on test_public_api --- src/py/tests/test_public_api.py | 232 +++++++++++++++++--------------- 1 file changed, 124 insertions(+), 108 deletions(-) diff --git a/src/py/tests/test_public_api.py b/src/py/tests/test_public_api.py index ebd9594b..4e1d8132 100644 --- a/src/py/tests/test_public_api.py +++ b/src/py/tests/test_public_api.py @@ -1,15 +1,13 @@ """Integrative tests for all public API functions in __init__.py using basic figures.""" import warnings +from pathlib import Path from unittest.mock import patch import pytest import kaleido -# allows to create a browser pool for tests -pytestmark = pytest.mark.asyncio(loop_scope="function") - @pytest.fixture def simple_figure(): @@ -18,7 +16,6 @@ def simple_figure(): import plotly.express as px with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) fig = px.line(x=[1, 2, 3, 4], y=[1, 2, 3, 4]) return fig @@ -28,141 +25,160 @@ async def test_calc_fig_basic(simple_figure): """Test calc_fig with a basic figure.""" result = await kaleido.calc_fig(simple_figure) assert isinstance(result, bytes) - assert len(result) > 0 - + assert result.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" -def test_calc_fig_sync_basic_server_running(simple_figure): - """Test calc_fig_sync when sync server is running.""" - with patch("kaleido._global_server.is_running", return_value=True), patch( - "kaleido._global_server.call_function", - return_value=b"test_bytes", - ) as mock_call: - result = kaleido.calc_fig_sync(simple_figure) - mock_call.assert_called_once_with("calc_fig", simple_figure) - assert result == b"test_bytes" +async def test_calc_fig_sync_both_scenarios(simple_figure): + """Test calc_fig_sync in both server running and not running scenarios.""" + # First get the expected result from calc_fig for comparison + expected_result = await kaleido.calc_fig(simple_figure) + assert isinstance(expected_result, bytes) + assert expected_result.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" + # Test scenario 1: server running + kaleido.start_sync_server(silence_warnings=True) + try: + with patch( + "kaleido._global_server.call_function", + wraps=kaleido._global_server.call_function, # noqa: SLF001 internal + ) as mock_call: + result_server_running = kaleido.calc_fig_sync(simple_figure) + + mock_call.assert_called_once() + assert isinstance(result_server_running, bytes) + assert result_server_running.startswith( + b"\x89PNG\r\n\x1a\n", + ), "Not a PNG file" + assert result_server_running == expected_result + finally: + kaleido.stop_sync_server(silence_warnings=True) -def test_calc_fig_sync_basic_server_not_running(simple_figure): - """Test calc_fig_sync when sync server is not running.""" - with patch("kaleido._global_server.is_running", return_value=False), patch( + # Test scenario 2: server not running + with patch( "kaleido._sync_server.oneshot_async_run", - return_value=b"test_bytes", + wraps=kaleido._sync_server.oneshot_async_run, # noqa: SLF001 internal ) as mock_oneshot: - result = kaleido.calc_fig_sync(simple_figure) + result_server_not_running = kaleido.calc_fig_sync(simple_figure) - mock_oneshot.assert_called_once_with( - kaleido.calc_fig, - args=(simple_figure,), - kwargs={}, - ) - assert result == b"test_bytes" + mock_oneshot.assert_called_once() + assert isinstance(result_server_not_running, bytes) + assert result_server_not_running.startswith( + b"\x89PNG\r\n\x1a\n", + ), "Not a PNG file" + assert result_server_not_running == expected_result async def test_write_fig_basic(simple_figure, tmp_path): - """Test write_fig with a basic figure.""" + """Test write_fig with a basic figure and compare with calc_fig output.""" output_file = tmp_path / "test_output.png" + # Get expected bytes from calc_fig + expected_bytes = await kaleido.calc_fig(simple_figure) + + # Write figure to file await kaleido.write_fig(simple_figure, path=str(output_file)) - # Check that file was created (actual implementation would create the file) - # For this test we're just ensuring the function runs without error + # Read the written file and compare + with Path(output_file).open("rb") as f: # noqa: ASYNC230 use aiofile + written_bytes = f.read() + assert written_bytes == expected_bytes + assert written_bytes.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" -def test_write_fig_sync_basic_server_running(simple_figure): - """Test write_fig_sync when sync server is running.""" - with patch("kaleido._global_server.is_running", return_value=True), patch( - "kaleido._global_server.call_function", - ) as mock_call: - kaleido.write_fig_sync(simple_figure, path="test.png") - mock_call.assert_called_once_with("write_fig", simple_figure, path="test.png") +async def test_write_fig_sync_both_scenarios(simple_figure, tmp_path): + """Test write_fig_sync and write_fig_from_object_sync in both server scenarios.""" + # Get expected bytes from calc_fig for comparison + expected_bytes = await kaleido.calc_fig(simple_figure) + assert expected_bytes.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" + + # Test scenario 1: server running + output_file_1 = tmp_path / "test_server_running.png" + output_file_from_object_1 = tmp_path / "test_from_object_server_running.png" + kaleido.start_sync_server(silence_warnings=True) + try: + with patch( + "kaleido._global_server.call_function", + wraps=kaleido._global_server.call_function, # noqa: SLF001 internal + ) as mock_call: + # Test write_fig_sync + kaleido.write_fig_sync(simple_figure, path=str(output_file_1)) + + # Test write_fig_from_object_sync + kaleido.write_fig_from_object_sync( + [ + { + "fig": simple_figure, + "path": output_file_from_object_1, + }, + ], + ) + + # Should have been called twice (once for each function) + assert mock_call.call_count == 2 # noqa: PLR2004 + + # Read and verify the written files + with Path(output_file_1).open("rb") as f: # noqa: ASYNC230 + written_bytes_1 = f.read() + assert written_bytes_1 == expected_bytes + + # Read and verify the written files + with Path(output_file_from_object_1).open("rb") as f: # noqa: ASYNC230 + from_object_written_bytes_1 = f.read() + assert from_object_written_bytes_1 == expected_bytes + finally: + kaleido.stop_sync_server(silence_warnings=True) -def test_write_fig_sync_basic_server_not_running(simple_figure): - """Test write_fig_sync when sync server is not running.""" - with patch("kaleido._global_server.is_running", return_value=False), patch( + # Test scenario 2: server not running + output_file_2 = tmp_path / "test_server_not_running.png" + output_file_from_object_2 = tmp_path / "test_from_object_server_not_running.png" + with patch( "kaleido._sync_server.oneshot_async_run", + wraps=kaleido._sync_server.oneshot_async_run, # noqa: SLF001 internal ) as mock_oneshot: - kaleido.write_fig_sync(simple_figure, path="test.png") - - mock_oneshot.assert_called_once_with( - kaleido.write_fig, - args=(simple_figure,), - kwargs={"path": "test.png"}, + # Test write_fig_sync + kaleido.write_fig_sync(simple_figure, path=str(output_file_2)) + + # Test write_fig_from_object_sync + kaleido.write_fig_from_object_sync( + [ + { + "fig": simple_figure, + "path": output_file_from_object_1, + }, + ], ) + # Should have been called twice (once for each function) + assert mock_oneshot.call_count == 2 # noqa: PLR2004 -async def test_write_fig_from_object_basic(simple_figure): - """Test write_fig_from_object with a basic figure generator.""" - generator = [simple_figure] - - # This should run without error - await kaleido.write_fig_from_object(generator) + # Read and verify the written files + with Path(output_file_2).open("rb") as f: # noqa: ASYNC230 + written_bytes_2 = f.read() + assert written_bytes_2 == expected_bytes - -def test_write_fig_from_object_sync_basic_server_running(simple_figure): - """Test write_fig_from_object_sync when sync server is running.""" - generator = [simple_figure] - - with patch("kaleido._global_server.is_running", return_value=True), patch( - "kaleido._global_server.call_function", - ) as mock_call: - kaleido.write_fig_from_object_sync(generator) - - mock_call.assert_called_once_with("write_fig_from_object", generator) - - -def test_write_fig_from_object_sync_basic_server_not_running(simple_figure): - """Test write_fig_from_object_sync when sync server is not running.""" - generator = [simple_figure] - - with patch("kaleido._global_server.is_running", return_value=False), patch( - "kaleido._sync_server.oneshot_async_run", - ) as mock_oneshot: - kaleido.write_fig_from_object_sync(generator) - - mock_oneshot.assert_called_once_with( - kaleido.write_fig_from_object, - args=(generator,), - kwargs={}, - ) + # Read and verify the written files + with Path(output_file_from_object_2).open("rb") as f: # noqa: ASYNC230 + from_object_written_bytes_2 = f.read() + assert from_object_written_bytes_2 == expected_bytes def test_start_stop_sync_server_integration(): - """Test start_sync_server and stop_sync_server together.""" - # Test that we can start and stop the server without errors - kaleido.start_sync_server(silence_warnings=True) - kaleido.stop_sync_server(silence_warnings=True) + """Test start_sync_server and stop_sync_server with warning behavior.""" + # Test starting and stopping with warnings silenced + kaleido.start_sync_server(silence_warnings=False) + # Test starting already started server - should warn + with pytest.warns(UserWarning, match="already"): + kaleido.start_sync_server(silence_warnings=False) -def test_sync_server_with_calc_fig_sync_integration(simple_figure): - """Integration test: start server, use calc_fig_sync, then stop server.""" - # Start server kaleido.start_sync_server(silence_warnings=True) - try: - # Use calc_fig_sync - this should use the running server - result = kaleido.calc_fig_sync(simple_figure) - assert isinstance(result, bytes) - assert len(result) > 0 + kaleido.stop_sync_server(silence_warnings=False) - finally: - # Always stop the server - kaleido.stop_sync_server(silence_warnings=True) + # Test stopping already stopped server - should warn + with pytest.warns(UserWarning, match="not running"): + kaleido.stop_sync_server(silence_warnings=False) - -def test_sync_server_with_write_fig_sync_integration(simple_figure, tmp_path): - """Integration test: start server, use write_fig_sync, then stop server.""" - output_file = tmp_path / "test_integration.png" - - # Start server - kaleido.start_sync_server(silence_warnings=True) - - try: - # Use write_fig_sync - this should use the running server - kaleido.write_fig_sync(simple_figure, path=str(output_file)) - - finally: - # Always stop the server - kaleido.stop_sync_server(silence_warnings=True) + kaleido.stop_sync_server(silence_warnings=True) From e4ba49cb6a391753c43af86d2e69491f1ad0ae39 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 20:21:08 -0500 Subject: [PATCH 014/167] Make corrections to public api tests. --- src/py/tests/test_public_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/tests/test_public_api.py b/src/py/tests/test_public_api.py index 4e1d8132..2cfc8e42 100644 --- a/src/py/tests/test_public_api.py +++ b/src/py/tests/test_public_api.py @@ -145,7 +145,7 @@ async def test_write_fig_sync_both_scenarios(simple_figure, tmp_path): [ { "fig": simple_figure, - "path": output_file_from_object_1, + "path": output_file_from_object_2, }, ], ) @@ -170,7 +170,7 @@ def test_start_stop_sync_server_integration(): kaleido.start_sync_server(silence_warnings=False) # Test starting already started server - should warn - with pytest.warns(UserWarning, match="already"): + with pytest.warns(RuntimeWarning, match="already"): kaleido.start_sync_server(silence_warnings=False) kaleido.start_sync_server(silence_warnings=True) @@ -178,7 +178,7 @@ def test_start_stop_sync_server_integration(): kaleido.stop_sync_server(silence_warnings=False) # Test stopping already stopped server - should warn - with pytest.warns(UserWarning, match="not running"): + with pytest.warns(RuntimeWarning, match="closed"): kaleido.stop_sync_server(silence_warnings=False) kaleido.stop_sync_server(silence_warnings=True) From 1898bd9f699c9e8e15269ed87f9e08ffd965ec57 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 21:48:55 -0500 Subject: [PATCH 015/167] Fix up test_init --- src/py/tests/test_init.py | 149 ++++++++++++-------------------------- 1 file changed, 47 insertions(+), 102 deletions(-) diff --git a/src/py/tests/test_init.py b/src/py/tests/test_init.py index 0c2641a0..c6a1543e 100644 --- a/src/py/tests/test_init.py +++ b/src/py/tests/test_init.py @@ -1,70 +1,55 @@ """Tests for wrapper functions in __init__.py that test argument passing.""" -from unittest.mock import MagicMock, patch - -import pytest +from unittest.mock import AsyncMock, patch import kaleido -@pytest.fixture -def mock_kaleido_context(): - """Fixture that provides a mocked Kaleido context manager.""" - mock_kaleido = MagicMock() - mock_kaleido.__aenter__ = MagicMock(return_value=mock_kaleido) - mock_kaleido.__aexit__ = MagicMock(return_value=False) - return mock_kaleido - - @patch("kaleido._sync_server.GlobalKaleidoServer.open") def test_start_sync_server_passes_args(mock_open): - """Test that start_sync_server passes args to GlobalKaleidoServer.open.""" + """Test that start_sync_server passes args and silence_warnings correctly.""" + # Test with silence_warnings=False (default) args = ("arg1", "arg2") kwargs = {"key1": "value1", "key2": "value2"} kaleido.start_sync_server(*args, **kwargs) + mock_open.assert_called_with(*args, silence_warnings=False, **kwargs) - mock_open.assert_called_once_with(*args, silence_warnings=False, **kwargs) - - -@patch("kaleido._sync_server.GlobalKaleidoServer.open") -def test_start_sync_server_silence_warnings(mock_open): - """Test that start_sync_server passes silence_warnings parameter correctly.""" + # Reset mock and test with silence_warnings=True + mock_open.reset_mock() args = ("arg1",) kwargs = {"key1": "value1"} kaleido.start_sync_server(*args, silence_warnings=True, **kwargs) - - mock_open.assert_called_once_with(*args, silence_warnings=True, **kwargs) + mock_open.assert_called_with(*args, silence_warnings=True, **kwargs) @patch("kaleido._sync_server.GlobalKaleidoServer.close") def test_stop_sync_server_passes_args(mock_close): - """Test that stop_sync_server passes silence_warnings to GlobalKaleidoServer.""" + """Test that stop_sync_server passes silence_warnings correctly.""" + # Test with silence_warnings=False (default) kaleido.stop_sync_server() + mock_close.assert_called_with(silence_warnings=False) - mock_close.assert_called_once_with(silence_warnings=False) - - -@patch("kaleido._sync_server.GlobalKaleidoServer.close") -def test_stop_sync_server_silence_warnings(mock_close): - """Test that stop_sync_server passes silence_warnings=True correctly.""" + # Reset mock and test with silence_warnings=True + mock_close.reset_mock() kaleido.stop_sync_server(silence_warnings=True) - - mock_close.assert_called_once_with(silence_warnings=True) + mock_close.assert_called_with(silence_warnings=True) @patch("kaleido.Kaleido") -@pytest.mark.asyncio -async def test_calc_fig_passes_args_and_forces_n_to_1( - mock_kaleido_class, - mock_kaleido_context, -): - """Test that calc_fig passes args correctly and forces n=1 in kopts.""" - mock_kaleido_context.calc_fig.return_value = b"test_bytes" - mock_kaleido_class.return_value = mock_kaleido_context +async def test_async_wrapper_functions(mock_kaleido_class): + """Test all async wrapper functions pass arguments correctly.""" + # Create a mock that doesn't need the context fixture + mock_kaleido = AsyncMock() + mock_kaleido.__aenter__.return_value = mock_kaleido + mock_kaleido.__aexit__.return_value = None + mock_kaleido.calc_fig.return_value = b"test_bytes" + mock_kaleido_class.return_value = mock_kaleido fig = {"data": []} + + # Test calc_fig with full arguments and kopts forcing n=1 path = "test.png" opts = {"width": 800} topojson = "test_topojson" @@ -72,92 +57,52 @@ async def test_calc_fig_passes_args_and_forces_n_to_1( result = await kaleido.calc_fig(fig, path, opts, topojson=topojson, kopts=kopts) - # Check that Kaleido was instantiated with kopts including n=1 expected_kopts = {"some_option": "value", "n": 1} - mock_kaleido_class.assert_called_once_with(**expected_kopts) - - # Check that calc_fig was called with correct arguments - mock_kaleido_context.calc_fig.assert_called_once_with( + mock_kaleido_class.assert_called_with(**expected_kopts) + mock_kaleido.calc_fig.assert_called_with( fig, path=path, opts=opts, topojson=topojson, ) - assert result == b"test_bytes" + # Reset mocks + mock_kaleido_class.reset_mock() + mock_kaleido.calc_fig.reset_mock() -@patch("kaleido.Kaleido") -@pytest.mark.asyncio -async def test_calc_fig_empty_kopts(mock_kaleido_class, mock_kaleido_context): - """Test that calc_fig works with empty kopts.""" - mock_kaleido_context.calc_fig.return_value = b"test_bytes" - mock_kaleido_class.return_value = mock_kaleido_context - - fig = {"data": []} - + # Test calc_fig with empty kopts await kaleido.calc_fig(fig) + mock_kaleido_class.assert_called_with(n=1) - # Check that Kaleido was instantiated with only n=1 - mock_kaleido_class.assert_called_once_with(n=1) - - -@patch("kaleido.Kaleido") -@pytest.mark.asyncio -async def test_write_fig_passes_args(mock_kaleido_class, mock_kaleido_context): - """Test that write_fig passes all arguments correctly.""" - mock_kaleido_class.return_value = mock_kaleido_context - - fig = {"data": []} - path = "test.png" - opts = {"width": 800} - topojson = "test_topojson" - kopts = {"some_option": "value"} + # Reset mocks + mock_kaleido_class.reset_mock() + mock_kaleido.write_fig.reset_mock() + # Test write_fig with full arguments await kaleido.write_fig(fig, path, opts, topojson=topojson, kopts=kopts) - - # Check that Kaleido was instantiated with kopts - mock_kaleido_class.assert_called_once_with(**kopts) - - # Check that write_fig was called with correct arguments - mock_kaleido_context.write_fig.assert_called_once_with( + mock_kaleido_class.assert_called_with(**kopts) # write_fig doesn't force n=1 + mock_kaleido.write_fig.assert_called_with( fig, path=path, opts=opts, topojson=topojson, ) + # Reset mocks + mock_kaleido_class.reset_mock() + mock_kaleido.write_fig.reset_mock() -@patch("kaleido.Kaleido") -@pytest.mark.asyncio -async def test_write_fig_empty_kopts(mock_kaleido_class, mock_kaleido_context): - """Test that write_fig works with empty kopts.""" - mock_kaleido_class.return_value = mock_kaleido_context - - fig = {"data": []} - + # Test write_fig with empty kopts await kaleido.write_fig(fig) + mock_kaleido_class.assert_called_with() - # Check that Kaleido was instantiated with empty dict - mock_kaleido_class.assert_called_once_with() - - -@patch("kaleido.Kaleido") -@pytest.mark.asyncio -async def test_write_fig_from_object_passes_args( - mock_kaleido_class, - mock_kaleido_context, -): - """Test that write_fig_from_object passes all arguments correctly.""" - mock_kaleido_class.return_value = mock_kaleido_context + # Reset mocks + mock_kaleido_class.reset_mock() + mock_kaleido.write_fig_from_object.reset_mock() + # Test write_fig_from_object generator = [{"data": []}] - kopts = {"some_option": "value"} - await kaleido.write_fig_from_object(generator, kopts=kopts) - - # Check that Kaleido was instantiated with kopts - mock_kaleido_class.assert_called_once_with(**kopts) - - # Check that write_fig_from_object was called with correct arguments - mock_kaleido_context.write_fig_from_object.assert_called_once_with(generator) + mock_kaleido_class.assert_called_with(**kopts) + mock_kaleido.write_fig_from_object.assert_called_with(generator) From 4147850b645a23dd66a4c039aaa171ed87bbe20b Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 21:52:31 -0500 Subject: [PATCH 016/167] Add comments. --- src/py/tests/test_init.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/py/tests/test_init.py b/src/py/tests/test_init.py index c6a1543e..1e99c10d 100644 --- a/src/py/tests/test_init.py +++ b/src/py/tests/test_init.py @@ -4,6 +4,9 @@ import kaleido +# Pretty complicated for basically testing a bunch of wrappers, but it works. +# Integration tests seem more important. + @patch("kaleido._sync_server.GlobalKaleidoServer.open") def test_start_sync_server_passes_args(mock_open): From 7a808b429555493e1610b9b27c6ae1a2c8619cc6 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 21:59:11 -0500 Subject: [PATCH 017/167] Reorganize a bit. --- src/py/tests/test_init.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/py/tests/test_init.py b/src/py/tests/test_init.py index 1e99c10d..131df7a5 100644 --- a/src/py/tests/test_init.py +++ b/src/py/tests/test_init.py @@ -44,11 +44,10 @@ def test_stop_sync_server_passes_args(mock_close): async def test_async_wrapper_functions(mock_kaleido_class): """Test all async wrapper functions pass arguments correctly.""" # Create a mock that doesn't need the context fixture - mock_kaleido = AsyncMock() + mock_kaleido_class.return_value = mock_kaleido = AsyncMock() mock_kaleido.__aenter__.return_value = mock_kaleido mock_kaleido.__aexit__.return_value = None mock_kaleido.calc_fig.return_value = b"test_bytes" - mock_kaleido_class.return_value = mock_kaleido fig = {"data": []} From abcc184d6e3f6c1bd1f5f74a92c88a251a80bdeb Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 22 Aug 2025 22:01:28 -0500 Subject: [PATCH 018/167] Make error_log/profiler 100% optional in __init__ --- src/py/kaleido/__init__.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/py/kaleido/__init__.py b/src/py/kaleido/__init__.py index 2591ae1f..71c18427 100644 --- a/src/py/kaleido/__init__.py +++ b/src/py/kaleido/__init__.py @@ -107,15 +107,14 @@ async def calc_fig( ) -async def write_fig( # noqa: PLR0913 (too many args, complexity) +async def write_fig( fig: Figurish, path: str | None | Path = None, opts: LayoutOpts | None = None, *, topojson: str | None = None, kopts: dict[str, Any] | None = None, - error_log=None, - profiler=None, + **kwargs, ): """ Write a plotly figure(s) to a file. @@ -135,8 +134,7 @@ async def write_fig( # noqa: PLR0913 (too many args, complexity) path=path, opts=opts, topojson=topojson, - error_log=error_log, - profiler=profiler, + **kwargs, ) @@ -144,8 +142,7 @@ async def write_fig_from_object( generator: AnyIterable, # this could be more specific with [] *, kopts: dict[str, Any] | None = None, - error_log=None, - profiler=None, + **kwargs, ): """ Write a plotly figure(s) to a file. @@ -162,8 +159,7 @@ async def write_fig_from_object( async with Kaleido(**(kopts or {})) as k: await k.write_fig_from_object( generator, - error_log=error_log, - profiler=profiler, + **kwargs, ) From 24d0dab3c205a9cb8882766e38f63bef157d6182 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 12:49:19 -0500 Subject: [PATCH 019/167] Iterate with claude on public_api test. --- src/py/tests/test_public_api.py | 210 ++++++++++++++++---------------- 1 file changed, 108 insertions(+), 102 deletions(-) diff --git a/src/py/tests/test_public_api.py b/src/py/tests/test_public_api.py index 2cfc8e42..1e50f923 100644 --- a/src/py/tests/test_public_api.py +++ b/src/py/tests/test_public_api.py @@ -1,6 +1,5 @@ """Integrative tests for all public API functions in __init__.py using basic figures.""" -import warnings from pathlib import Path from unittest.mock import patch @@ -15,153 +14,160 @@ def simple_figure(): # ruff: noqa: PLC0415 import plotly.express as px - with warnings.catch_warnings(): - fig = px.line(x=[1, 2, 3, 4], y=[1, 2, 3, 4]) + fig = px.line(x=[1, 2, 3, 4], y=[1, 2, 3, 4]) return fig -async def test_calc_fig_basic(simple_figure): - """Test calc_fig with a basic figure.""" - result = await kaleido.calc_fig(simple_figure) - assert isinstance(result, bytes) - assert result.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" +async def test_async_api_functions(simple_figure, tmp_path): + """Test calc_fig, write_fig, and write_fig_from_object with cross-validation.""" + # Test calc_fig and get reference bytes + calc_result = await kaleido.calc_fig(simple_figure) + assert isinstance(calc_result, bytes) + assert calc_result.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" + # Test write_fig and compare with calc_fig output + write_fig_output = tmp_path / "test_write_fig.png" + await kaleido.write_fig(simple_figure, path=str(write_fig_output)) -async def test_calc_fig_sync_both_scenarios(simple_figure): - """Test calc_fig_sync in both server running and not running scenarios.""" - # First get the expected result from calc_fig for comparison - expected_result = await kaleido.calc_fig(simple_figure) - assert isinstance(expected_result, bytes) - assert expected_result.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" + with Path(write_fig_output).open("rb") as f: # noqa: ASYNC230 + write_fig_bytes = f.read() - # Test scenario 1: server running - kaleido.start_sync_server(silence_warnings=True) - try: - with patch( - "kaleido._global_server.call_function", - wraps=kaleido._global_server.call_function, # noqa: SLF001 internal - ) as mock_call: - result_server_running = kaleido.calc_fig_sync(simple_figure) - - mock_call.assert_called_once() - assert isinstance(result_server_running, bytes) - assert result_server_running.startswith( - b"\x89PNG\r\n\x1a\n", - ), "Not a PNG file" - assert result_server_running == expected_result - finally: - kaleido.stop_sync_server(silence_warnings=True) + assert write_fig_bytes == calc_result + assert write_fig_bytes.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" - # Test scenario 2: server not running - with patch( - "kaleido._sync_server.oneshot_async_run", - wraps=kaleido._sync_server.oneshot_async_run, # noqa: SLF001 internal - ) as mock_oneshot: - result_server_not_running = kaleido.calc_fig_sync(simple_figure) + # Test write_fig_from_object and compare with calc_fig output + write_fig_from_object_output = tmp_path / "test_write_fig_from_object.png" + await kaleido.write_fig_from_object( + [ + { + "fig": simple_figure, + "path": write_fig_from_object_output, + }, + ], + ) - mock_oneshot.assert_called_once() - assert isinstance(result_server_not_running, bytes) - assert result_server_not_running.startswith( - b"\x89PNG\r\n\x1a\n", - ), "Not a PNG file" - assert result_server_not_running == expected_result + with Path(write_fig_from_object_output).open("rb") as f: # noqa: ASYNC230 + write_fig_from_object_bytes = f.read() + assert write_fig_from_object_bytes == calc_result + assert write_fig_from_object_bytes.startswith( + b"\x89PNG\r\n\x1a\n", + ), "Not a PNG file" -async def test_write_fig_basic(simple_figure, tmp_path): - """Test write_fig with a basic figure and compare with calc_fig output.""" - output_file = tmp_path / "test_output.png" + # Cross-validate all results are identical + assert write_fig_bytes == write_fig_from_object_bytes == calc_result - # Get expected bytes from calc_fig - expected_bytes = await kaleido.calc_fig(simple_figure) - # Write figure to file - await kaleido.write_fig(simple_figure, path=str(output_file)) - - # Read the written file and compare - with Path(output_file).open("rb") as f: # noqa: ASYNC230 use aiofile - written_bytes = f.read() - - assert written_bytes == expected_bytes - assert written_bytes.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" - - -async def test_write_fig_sync_both_scenarios(simple_figure, tmp_path): - """Test write_fig_sync and write_fig_from_object_sync in both server scenarios.""" +async def test_sync_api_functions(simple_figure, tmp_path): + """Test sync wrappers with cross-validation.""" # Get expected bytes from calc_fig for comparison expected_bytes = await kaleido.calc_fig(simple_figure) + assert isinstance(expected_bytes, bytes) assert expected_bytes.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" # Test scenario 1: server running - output_file_1 = tmp_path / "test_server_running.png" - output_file_from_object_1 = tmp_path / "test_from_object_server_running.png" - kaleido.start_sync_server(silence_warnings=True) - try: - with patch( - "kaleido._global_server.call_function", - wraps=kaleido._global_server.call_function, # noqa: SLF001 internal - ) as mock_call: + write_fig_output_1 = tmp_path / "test_write_fig_server_running.png" + write_fig_from_object_output_1 = tmp_path / "test_from_object_server_running.png" + + with patch( + "kaleido._sync_server.oneshot_async_run", + wraps=kaleido._sync_server.oneshot_async_run, # noqa: SLF001 internal + ) as mock_oneshot, patch( + "kaleido._global_server.call_function", + wraps=kaleido._global_server.call_function, # noqa: SLF001 internal + ) as mock_call: + kaleido.start_sync_server(silence_warnings=True) + try: + # Test calc_fig_sync + calc_result_1 = kaleido.calc_fig_sync(simple_figure) + assert isinstance(calc_result_1, bytes) + assert calc_result_1.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" + assert calc_result_1 == expected_bytes + # Test write_fig_sync - kaleido.write_fig_sync(simple_figure, path=str(output_file_1)) + kaleido.write_fig_sync(simple_figure, path=str(write_fig_output_1)) + + with Path(write_fig_output_1).open("rb") as f: # noqa: ASYNC230 + write_fig_bytes_1 = f.read() + assert write_fig_bytes_1 == expected_bytes + assert write_fig_bytes_1.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" # Test write_fig_from_object_sync kaleido.write_fig_from_object_sync( [ { "fig": simple_figure, - "path": output_file_from_object_1, + "path": write_fig_from_object_output_1, }, ], ) - # Should have been called twice (once for each function) - assert mock_call.call_count == 2 # noqa: PLR2004 + with Path(write_fig_from_object_output_1).open("rb") as f: # noqa: ASYNC230 + from_object_bytes_1 = f.read() + assert from_object_bytes_1 == expected_bytes + assert from_object_bytes_1.startswith( + b"\x89PNG\r\n\x1a\n", + ), "Not a PNG file" - # Read and verify the written files - with Path(output_file_1).open("rb") as f: # noqa: ASYNC230 - written_bytes_1 = f.read() - assert written_bytes_1 == expected_bytes + # Should have been called three times (once for each function) + assert mock_call.call_count == 3 # noqa: PLR2004 + assert mock_oneshot.call_count == 0 - # Read and verify the written files - with Path(output_file_from_object_1).open("rb") as f: # noqa: ASYNC230 - from_object_written_bytes_1 = f.read() - assert from_object_written_bytes_1 == expected_bytes + # Cross-validate all server running results are identical + assert ( + calc_result_1 + == write_fig_bytes_1 + == from_object_bytes_1 + == expected_bytes + ) - finally: - kaleido.stop_sync_server(silence_warnings=True) + finally: + kaleido.stop_sync_server(silence_warnings=True) + + # Test scenario 2: server not running + write_fig_output_2 = tmp_path / "test_write_fig_server_not_running.png" + write_fig_from_object_output_2 = ( + tmp_path / "test_from_object_server_not_running.png" + ) + + # Test calc_fig_sync + calc_result_2 = kaleido.calc_fig_sync(simple_figure) + assert isinstance(calc_result_2, bytes) + assert calc_result_2.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" + assert calc_result_2 == expected_bytes - # Test scenario 2: server not running - output_file_2 = tmp_path / "test_server_not_running.png" - output_file_from_object_2 = tmp_path / "test_from_object_server_not_running.png" - with patch( - "kaleido._sync_server.oneshot_async_run", - wraps=kaleido._sync_server.oneshot_async_run, # noqa: SLF001 internal - ) as mock_oneshot: # Test write_fig_sync - kaleido.write_fig_sync(simple_figure, path=str(output_file_2)) + kaleido.write_fig_sync(simple_figure, path=str(write_fig_output_2)) + + with Path(write_fig_output_2).open("rb") as f: # noqa: ASYNC230 + write_fig_bytes_2 = f.read() + assert write_fig_bytes_2 == expected_bytes + assert write_fig_bytes_2.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" # Test write_fig_from_object_sync kaleido.write_fig_from_object_sync( [ { "fig": simple_figure, - "path": output_file_from_object_2, + "path": write_fig_from_object_output_2, }, ], ) - # Should have been called twice (once for each function) - assert mock_oneshot.call_count == 2 # noqa: PLR2004 + with Path(write_fig_from_object_output_2).open("rb") as f: # noqa: ASYNC230 + from_object_bytes_2 = f.read() + assert from_object_bytes_2 == expected_bytes + assert from_object_bytes_2.startswith(b"\x89PNG\r\n\x1a\n"), "Not a PNG file" - # Read and verify the written files - with Path(output_file_2).open("rb") as f: # noqa: ASYNC230 - written_bytes_2 = f.read() - assert written_bytes_2 == expected_bytes + # Should have been called three times (once for each function) + assert mock_call.call_count == 3 # noqa: PLR2004 + assert mock_oneshot.call_count == 3 # noqa: PLR2004 - # Read and verify the written files - with Path(output_file_from_object_2).open("rb") as f: # noqa: ASYNC230 - from_object_written_bytes_2 = f.read() - assert from_object_written_bytes_2 == expected_bytes + # Cross-validate all server not running results are identical + assert ( + calc_result_2 == write_fig_bytes_2 == from_object_bytes_2 == expected_bytes + ) def test_start_stop_sync_server_integration(): From f5cc483a0ba0df67aec546d6868d29f2927516a6 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 13:53:32 -0500 Subject: [PATCH 020/167] Parameterize public api tests for fig/to_dict() --- src/py/tests/test_public_api.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/py/tests/test_public_api.py b/src/py/tests/test_public_api.py index 1e50f923..16fc4767 100644 --- a/src/py/tests/test_public_api.py +++ b/src/py/tests/test_public_api.py @@ -8,14 +8,16 @@ import kaleido -@pytest.fixture -def simple_figure(): - """Create a simple plotly figure for testing.""" +@pytest.fixture(params=["figure", "dict"]) +def simple_figure(request): + """Create a simple plotly figure for testing, either as figure or dict.""" # ruff: noqa: PLC0415 import plotly.express as px fig = px.line(x=[1, 2, 3, 4], y=[1, 2, 3, 4]) + if request.param == "dict": + return fig.to_dict() return fig From 9489b717bf5e1d344e7444657f7d8066430b047a Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 14:38:42 -0500 Subject: [PATCH 021/167] Change to using args fixtures. --- src/py/tests/test_init.py | 80 +++++++++++++++++++++++++++++++++++---- 1 file changed, 72 insertions(+), 8 deletions(-) diff --git a/src/py/tests/test_init.py b/src/py/tests/test_init.py index 131df7a5..79fb5ba3 100644 --- a/src/py/tests/test_init.py +++ b/src/py/tests/test_init.py @@ -2,27 +2,37 @@ from unittest.mock import AsyncMock, patch +import pytest + import kaleido # Pretty complicated for basically testing a bunch of wrappers, but it works. # Integration tests seem more important. +# I much prefer the public_api file, this set of tests can be considered +# for deletion. + + +@pytest.fixture +def args(): + """Basic args for sync wrapper tests.""" + return ({"data": []}, "test.png") + + +@pytest.fixture +def kwargs(): + """Basic kwargs for sync wrapper tests.""" + return {"width": 800} @patch("kaleido._sync_server.GlobalKaleidoServer.open") -def test_start_sync_server_passes_args(mock_open): +def test_start_sync_server_passes_args(mock_open, args, kwargs): """Test that start_sync_server passes args and silence_warnings correctly.""" # Test with silence_warnings=False (default) - args = ("arg1", "arg2") - kwargs = {"key1": "value1", "key2": "value2"} - kaleido.start_sync_server(*args, **kwargs) mock_open.assert_called_with(*args, silence_warnings=False, **kwargs) # Reset mock and test with silence_warnings=True mock_open.reset_mock() - args = ("arg1",) - kwargs = {"key1": "value1"} - kaleido.start_sync_server(*args, silence_warnings=True, **kwargs) mock_open.assert_called_with(*args, silence_warnings=True, **kwargs) @@ -42,7 +52,11 @@ def test_stop_sync_server_passes_args(mock_close): @patch("kaleido.Kaleido") async def test_async_wrapper_functions(mock_kaleido_class): - """Test all async wrapper functions pass arguments correctly.""" + """Test all async wrapper functions pass arguments correctly. + + Note: This test uses fixed args rather than fixtures due to specific + requirements with topojson and kopts that don't match the simple fixture pattern. + """ # Create a mock that doesn't need the context fixture mock_kaleido_class.return_value = mock_kaleido = AsyncMock() mock_kaleido.__aenter__.return_value = mock_kaleido @@ -108,3 +122,53 @@ async def test_async_wrapper_functions(mock_kaleido_class): await kaleido.write_fig_from_object(generator, kopts=kopts) mock_kaleido_class.assert_called_with(**kopts) mock_kaleido.write_fig_from_object.assert_called_with(generator) + + +@patch("kaleido._sync_server.GlobalKaleidoServer.is_running") +@patch("kaleido._sync_server.GlobalKaleidoServer.call_function") +def test_sync_wrapper_server(mock_call_function, mock_is_running, args, kwargs): + """Test all sync wrapper functions when global server is running.""" + mock_is_running.return_value = True + + # Test calc_fig_sync + kaleido.calc_fig_sync(*args, **kwargs) + mock_call_function.assert_called_with("calc_fig", *args, **kwargs) + + mock_call_function.reset_mock() + + # Test write_fig_sync + kaleido.write_fig_sync(*args, **kwargs) + mock_call_function.assert_called_with("write_fig", *args, **kwargs) + + mock_call_function.reset_mock() + + # Test write_fig_from_object_sync + kaleido.write_fig_from_object_sync(*args, **kwargs) + mock_call_function.assert_called_with("write_fig_from_object", *args, **kwargs) + + +@patch("kaleido._sync_server.GlobalKaleidoServer.is_running") +@patch("kaleido._sync_server.oneshot_async_run") +def test_sync_wrapper_oneshot(mock_oneshot_run, mock_is_running, args, kwargs): + """Test all sync wrapper functions when no server is running.""" + mock_is_running.return_value = False + + # Test calc_fig_sync + kaleido.calc_fig_sync(*args, **kwargs) + mock_oneshot_run.assert_called_with(kaleido.calc_fig, args=args, kwargs=kwargs) + + mock_oneshot_run.reset_mock() + + # Test write_fig_sync + kaleido.write_fig_sync(*args, **kwargs) + mock_oneshot_run.assert_called_with(kaleido.write_fig, args=args, kwargs=kwargs) + + mock_oneshot_run.reset_mock() + + # Test write_fig_from_object_sync + kaleido.write_fig_from_object_sync(*args, **kwargs) + mock_oneshot_run.assert_called_with( + kaleido.write_fig_from_object, + args=args, + kwargs=kwargs, + ) From 824230c3552e7ccd50c87e88a700e95c9aabd59a Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 15:20:07 -0500 Subject: [PATCH 022/167] Remove regex for html parsing. --- src/py/tests/test_page_generator.py | 220 ++++++++++++---------------- 1 file changed, 96 insertions(+), 124 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index ad72bf52..7f353db5 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -1,5 +1,5 @@ -import re import sys +from html.parser import HTMLParser from importlib.util import find_spec import logistro @@ -13,130 +13,79 @@ _logger = logistro.getLogger(__name__) -_re_default_mathjax = re.escape(DEFAULT_MATHJAX) -_re_default_plotly = re.escape(DEFAULT_PLOTLY) -no_imports_result_raw = ( - r''' - +# Expected boilerplate HTML (without script tags with src) +EXPECTED_BOILERPLATE = """ Kaleido-fier - - - - - - -""" -) -no_imports_result_re = re.compile(no_imports_result_raw) - -all_defaults_re = re.compile( - r''' - - - - - Kaleido-fier - - - - - - - -""", -) +""" -with_plot_result_re = re.compile( - r''' - - - - - Kaleido-fier - - - - - - - - -""", -) +# Claude, please review this for obvious errors. +class HTMLAnalyzer(HTMLParser): + """Extract script tags with src attributes and return HTML without them.""" -without_math_result_re = re.compile(r""" - - - - - Kaleido-fier - - + def __init__(self): + super().__init__() + self.scripts = [] + self.boilerplate = [] + self._in_script = False - - - - - -""") + def handle_starttag(self, tag, attrs): + if tag == "script" and "src" in (attr_dict := dict(attrs)): + self._in_script = True + self.scripts.append(attr_dict["src"]) + return + self.boilerplate.append(self.get_starttag_text()) -with_others_result_raw = r""" - - - - - Kaleido-fier - - + def handle_endtag(self, tag): + if self._in_script and tag == "script": + self._in_script = False + return + self.boilerplate.append(self.get_endtag_text()) - - - - - - - - -""" -with_others_result_re = re.compile(with_others_result_raw) + def handle_data(self, data): + if not self._in_script: + self.boilerplate.append(data) + + +# Create boilerplate reference by parsing expected HTML +_reference_analyzer = HTMLAnalyzer() +_reference_analyzer.feed(EXPECTED_BOILERPLATE) +_REFERENCE_BOILERPLATE = "\n".join(_reference_analyzer.boilerplate) + + +def get_scripts_from_html(generated_html): + """ + Parse generated HTML, assert boilerplate matches reference, and return script URLs. + + Returns: + list: script src URLs found in generated HTML + """ + analyzer = HTMLAnalyzer() + analyzer.feed(generated_html) + + generated_boilerplate = "\n".join(analyzer.boilerplate) + + # Assert boilerplate matches with diff on failure + assert generated_boilerplate == _REFERENCE_BOILERPLATE, ( + f"Boilerplate mismatch:\n" + f"Expected:\n{_REFERENCE_BOILERPLATE}\n\n" + f"Got:\n{generated_boilerplate}" + ) + + return analyzer.scripts @pytest.mark.order(1) @@ -155,37 +104,60 @@ async def test_page_generator(): "or if you really need to, run it separately and then skip it " "in the main group.", ) + + # Test no imports (plotly not available) no_imports = PageGenerator().generate_index() - assert no_imports_result_re.findall(no_imports), ( - f"{len(no_imports_result_raw)}: {no_imports_result_raw}" - "\n" - f"{len(no_imports)}: {no_imports}" - ) + scripts = get_scripts_from_html(no_imports) + + # Should have mathjax, plotly default, and kaleido_scopes + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == DEFAULT_PLOTLY + assert scripts[2].endswith("kaleido_scopes.js") + sys.path = old_path - # this imports plotly so above test must have already been done + # Test all defaults (plotly available) all_defaults = PageGenerator().generate_index() - assert all_defaults_re.findall(all_defaults) + scripts = get_scripts_from_html(all_defaults) + + # Should have mathjax, plotly package data, and kaleido_scopes + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1].endswith("package_data/plotly.min.js") + assert scripts[2].endswith("kaleido_scopes.js") + # Test with custom plotly with_plot = PageGenerator(plotly="https://with_plot").generate_index() - assert with_plot_result_re.findall(with_plot) + scripts = get_scripts_from_html(with_plot) + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == "https://with_plot" + assert scripts[2].endswith("kaleido_scopes.js") + + # Test without mathjax without_math = PageGenerator( plotly="https://with_plot", mathjax=False, ).generate_index() - assert without_math_result_re.findall(without_math) + scripts = get_scripts_from_html(without_math) + + assert len(scripts) == 2 # noqa: PLR2004 + assert scripts[0] == "https://with_plot" + assert scripts[1].endswith("kaleido_scopes.js") + # Test with custom mathjax and others with_others = PageGenerator( plotly="https://with_plot", mathjax="https://with_mathjax", others=["https://1", "https://2"], ).generate_index() - assert with_others_result_re.findall(with_others), ( - f"{len(with_others_result_raw)}: {with_others_result_raw}" - "\n" - f"{len(with_others)}: {with_others}" - ) - - -# test others + scripts = get_scripts_from_html(with_others) + + assert len(scripts) == 5 # noqa: PLR2004 + assert scripts[0] == "https://with_mathjax" + assert scripts[1] == "https://with_plot" + assert scripts[2] == "https://1" + assert scripts[3] == "https://2" + assert scripts[4].endswith("kaleido_scopes.js") From d152afa7211975bbaa5f29d37397b946c0f06904 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 16:49:54 -0500 Subject: [PATCH 023/167] Fix non-existent function --- src/py/tests/test_page_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 7f353db5..1f14a6b3 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -53,7 +53,7 @@ def handle_endtag(self, tag): if self._in_script and tag == "script": self._in_script = False return - self.boilerplate.append(self.get_endtag_text()) + self.boilerplate.append(f"") def handle_data(self, data): if not self._in_script: From ec6290861bd237557e76a7a87557878d0aaab527 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 17:00:55 -0500 Subject: [PATCH 024/167] Fix whitespace errors. --- src/py/tests/test_page_generator.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 1f14a6b3..bc9f79d0 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -1,3 +1,4 @@ +import re import sys from html.parser import HTMLParser from importlib.util import find_spec @@ -60,10 +61,19 @@ def handle_data(self, data): self.boilerplate.append(data) +def normalize_whitespace(html): + """Normalize whitespace by collapsing multiple newlines and extra spaces.""" + # Collapse multiple newlines to single newlines + html = re.sub(r"\n\s*\n", "\n", html) + # Remove extra whitespace between tags + html = re.sub(r">\s*<", "><", html) + return html.strip() + + # Create boilerplate reference by parsing expected HTML _reference_analyzer = HTMLAnalyzer() _reference_analyzer.feed(EXPECTED_BOILERPLATE) -_REFERENCE_BOILERPLATE = "\n".join(_reference_analyzer.boilerplate) +_REFERENCE_BOILERPLATE = normalize_whitespace("".join(_reference_analyzer.boilerplate)) def get_scripts_from_html(generated_html): @@ -76,7 +86,7 @@ def get_scripts_from_html(generated_html): analyzer = HTMLAnalyzer() analyzer.feed(generated_html) - generated_boilerplate = "\n".join(analyzer.boilerplate) + generated_boilerplate = normalize_whitespace("".join(analyzer.boilerplate)) # Assert boilerplate matches with diff on failure assert generated_boilerplate == _REFERENCE_BOILERPLATE, ( From 930f3385ae549913f09dc7e6ee898378fc1ccd50 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 17:09:56 -0500 Subject: [PATCH 025/167] Add types for page_generaetor. --- src/py/kaleido/_page_generator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index fe5ad505..65f1f98d 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -16,7 +16,7 @@ KJS_PATH = Path(__file__).resolve().parent / "vendor" / "kaleido_scopes.js" -def _ensure_path(path: Path | str): +def _ensure_path(path: Path | str) -> None: _logger.debug(f"Ensuring path {path!s}") if urlparse(str(path)).scheme.startswith("http"): # is url return @@ -54,7 +54,14 @@ class PageGenerator: """ """The footer is the HTML that always goes on the bottom. Rarely needs changing.""" - def __init__(self, *, plotly=None, mathjax=None, others=None, force_cdn=False): + def __init__( + self, + *, + plotly: None | Path | str | tuple[Path | str, str] = None, + mathjax: None | Path | str | bool | tuple[Path | str, str] = None, + others: None | list[Path | str | tuple[Path | str, str]] = None, + force_cdn: bool = False, + ): """ Create a PageGenerator. From f9175fb21f4d51a1afd893de225d28826ca1954f Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 18:18:29 -0500 Subject: [PATCH 026/167] Add hypothesis tests generated by Claude. --- src/py/tests/test_page_generator.py | 330 +++++++++++++++++++++++++--- 1 file changed, 303 insertions(+), 27 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index bc9f79d0..b3e3a588 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -1,10 +1,14 @@ import re import sys +import tempfile from html.parser import HTMLParser from importlib.util import find_spec +from pathlib import Path import logistro import pytest +from hypothesis import given +from hypothesis import strategies as st from kaleido import PageGenerator from kaleido._page_generator import DEFAULT_MATHJAX, DEFAULT_PLOTLY @@ -33,7 +37,6 @@ """ -# Claude, please review this for obvious errors. class HTMLAnalyzer(HTMLParser): """Extract script tags with src attributes and return HTML without them.""" @@ -98,12 +101,77 @@ def get_scripts_from_html(generated_html): return analyzer.scripts +# Fixtures for user supplied input scenarios +@pytest.fixture +def temp_js_file(): + """Create a temporary JavaScript file that exists.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".js", delete=False) as f: + f.write("console.log('test');") + temp_path = Path(f.name) + yield temp_path + temp_path.unlink() + + +@pytest.fixture +def existing_file_path(): + """Return path to current test file (guaranteed to exist).""" + return Path(__file__) + + +@pytest.fixture +def nonexistent_file_path(): + """Return path to file that doesn't exist.""" + return Path("/nonexistent/path/file.js") + + +@pytest.fixture +def user_input_scenarios(): + """Fixture for user supplied input scenarios using hypothesis strategies.""" + + # Generate sample data using hypothesis strategies + http_urls = st.text( + min_size=1, + max_size=20, + alphabet=st.characters(whitelist_categories=("Lu", "Ll", "And")), + ).map(lambda x: f"https://example.com/{x}.js") + + return { + "custom_plotly_url": http_urls.example(), + "custom_mathjax_url": http_urls.example(), + "other_scripts": [ + http_urls.example(), + http_urls.example(), + ], + "plotly_with_encoding": (http_urls.example(), "utf-8"), + "mathjax_with_encoding": (http_urls.example(), "utf-16"), + } + + +@pytest.fixture +def hypothesis_urls(): + """Generate hypothesis-based URL strategies.""" + return st.text(min_size=1, max_size=20).map(lambda x: f"https://example.com/{x}.js") + + +@pytest.fixture +def hypothesis_encodings(): + """Generate hypothesis-based encoding strategies.""" + return st.sampled_from(["utf-8", "utf-16", "ascii", "latin1"]) + + +@pytest.fixture +def hypothesis_tuples(hypothesis_urls, hypothesis_encodings): + """Generate hypothesis-based (url, encoding) tuples.""" + return st.tuples(hypothesis_urls, hypothesis_encodings) + + +# Test default combinations @pytest.mark.order(1) -async def test_page_generator(): +async def test_defaults_no_plotly_available(): + """Test defaults when plotly package is not available.""" if not find_spec("plotly"): - raise ImportError( - "Tests must be run with plotly installed to function", - ) + raise ImportError("Tests must be run with plotly installed to function") + old_path = sys.path sys.path = sys.path[:1] if find_spec("plotly"): @@ -127,7 +195,9 @@ async def test_page_generator(): sys.path = old_path - # Test all defaults (plotly available) + +async def test_defaults_with_plotly_available(): + """Test defaults when plotly package is available.""" all_defaults = PageGenerator().generate_index() scripts = get_scripts_from_html(all_defaults) @@ -137,37 +207,243 @@ async def test_page_generator(): assert scripts[1].endswith("package_data/plotly.min.js") assert scripts[2].endswith("kaleido_scopes.js") - # Test with custom plotly - with_plot = PageGenerator(plotly="https://with_plot").generate_index() - scripts = get_scripts_from_html(with_plot) + +async def test_force_cdn(): + """Test force_cdn=True forces use of CDN plotly even when plotly is available.""" + # Verify plotly is available first + if not find_spec("plotly"): + pytest.skip("Plotly not available - cannot test force_cdn override") + + forced_cdn = PageGenerator(force_cdn=True).generate_index() + scripts = get_scripts_from_html(forced_cdn) assert len(scripts) == 3 # noqa: PLR2004 assert scripts[0] == DEFAULT_MATHJAX - assert scripts[1] == "https://with_plot" + assert scripts[1] == DEFAULT_PLOTLY assert scripts[2].endswith("kaleido_scopes.js") - # Test without mathjax - without_math = PageGenerator( - plotly="https://with_plot", - mathjax=False, - ).generate_index() - scripts = get_scripts_from_html(without_math) + +# Test boolean mathjax functionality +async def test_mathjax_false(): + """Test that mathjax=False disables mathjax.""" + without_mathjax = PageGenerator(mathjax=False).generate_index() + scripts = get_scripts_from_html(without_mathjax) assert len(scripts) == 2 # noqa: PLR2004 - assert scripts[0] == "https://with_plot" + assert scripts[0].endswith("package_data/plotly.min.js") assert scripts[1].endswith("kaleido_scopes.js") - # Test with custom mathjax and others - with_others = PageGenerator( - plotly="https://with_plot", - mathjax="https://with_mathjax", - others=["https://1", "https://2"], - ).generate_index() + +# Test user overrides +async def test_custom_plotly_url(user_input_scenarios): + """Test custom plotly URL override.""" + custom_plotly = user_input_scenarios["custom_plotly_url"] + with_custom = PageGenerator(plotly=custom_plotly).generate_index() + scripts = get_scripts_from_html(with_custom) + + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == custom_plotly + assert scripts[2].endswith("kaleido_scopes.js") + + +async def test_custom_mathjax_url(user_input_scenarios): + """Test custom mathjax URL override.""" + custom_mathjax = user_input_scenarios["custom_mathjax_url"] + with_custom = PageGenerator(mathjax=custom_mathjax).generate_index() + scripts = get_scripts_from_html(with_custom) + + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == custom_mathjax + assert scripts[1].endswith("package_data/plotly.min.js") + assert scripts[2].endswith("kaleido_scopes.js") + + +async def test_other_scripts(user_input_scenarios): + """Test adding other scripts.""" + other_scripts = user_input_scenarios["other_scripts"] + with_others = PageGenerator(others=other_scripts).generate_index() scripts = get_scripts_from_html(with_others) assert len(scripts) == 5 # noqa: PLR2004 - assert scripts[0] == "https://with_mathjax" - assert scripts[1] == "https://with_plot" - assert scripts[2] == "https://1" - assert scripts[3] == "https://2" + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1].endswith("package_data/plotly.min.js") + assert scripts[2] == other_scripts[0] + assert scripts[3] == other_scripts[1] + assert scripts[4].endswith("kaleido_scopes.js") + + +async def test_combined_overrides(user_input_scenarios): + """Test combination of multiple overrides.""" + custom_plotly = user_input_scenarios["custom_plotly_url"] + custom_mathjax = user_input_scenarios["custom_mathjax_url"] + other_scripts = user_input_scenarios["other_scripts"] + + combined = PageGenerator( + plotly=custom_plotly, + mathjax=custom_mathjax, + others=other_scripts, + ).generate_index() + scripts = get_scripts_from_html(combined) + + assert len(scripts) == 5 # noqa: PLR2004 + assert scripts[0] == custom_mathjax + assert scripts[1] == custom_plotly + assert scripts[2] == other_scripts[0] + assert scripts[3] == other_scripts[1] assert scripts[4].endswith("kaleido_scopes.js") + + +# Test file path validation +async def test_existing_file_path(temp_js_file): + """Test that existing file paths work with and without file:/// protocol.""" + # Test with regular path + generator = PageGenerator(plotly=str(temp_js_file)) + html = generator.generate_index() + scripts = get_scripts_from_html(html) + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == str(temp_js_file) + assert scripts[2].endswith("kaleido_scopes.js") + + # Test with file:/// protocol + generator_uri = PageGenerator(plotly=temp_js_file.as_uri()) + html_uri = generator_uri.generate_index() + scripts_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() + assert scripts_uri[2].endswith("kaleido_scopes.js") + + +async def test_nonexistent_file_path_raises_error(nonexistent_file_path): + """Test that nonexistent file paths raise FileNotFoundError.""" + # Test with regular path + with pytest.raises(FileNotFoundError): + PageGenerator(plotly=str(nonexistent_file_path)) + + # Test with file:/// protocol + with pytest.raises(FileNotFoundError): + PageGenerator(plotly=nonexistent_file_path.as_uri()) + + +async def test_mathjax_nonexistent_file_raises_error(nonexistent_file_path): + """Test that nonexistent mathjax file raises FileNotFoundError.""" + # Test with regular path + with pytest.raises(FileNotFoundError): + PageGenerator(mathjax=str(nonexistent_file_path)) + + # Test with file:/// protocol + with pytest.raises(FileNotFoundError): + PageGenerator(mathjax=nonexistent_file_path.as_uri()) + + +async def test_others_nonexistent_file_raises_error(nonexistent_file_path): + """Test that nonexistent file in others list raises FileNotFoundError.""" + # Test with regular path + with pytest.raises(FileNotFoundError): + PageGenerator(others=[str(nonexistent_file_path)]) + + # Test with file:/// protocol + with pytest.raises(FileNotFoundError): + PageGenerator(others=[nonexistent_file_path.as_uri()]) + + +# Test HTTP URLs (should not raise FileNotFoundError) +async def test_http_urls_skip_file_validation(): + """Test that HTTP URLs skip file existence validation.""" + # These should not raise FileNotFoundError even if URLs don't exist + generator = PageGenerator( + plotly="https://nonexistent.example.com/plotly.js", + mathjax="https://nonexistent.example.com/mathjax.js", + others=["https://nonexistent.example.com/other.js"], + ) + html = generator.generate_index() + scripts = get_scripts_from_html(html) + + assert len(scripts) == 4 # noqa: PLR2004 + assert scripts[0] == "https://nonexistent.example.com/mathjax.js" + assert scripts[1] == "https://nonexistent.example.com/plotly.js" + assert scripts[2] == "https://nonexistent.example.com/other.js" + assert scripts[3].endswith("kaleido_scopes.js") + + +# Test tuple (path, encoding) functionality +async def test_plotly_with_encoding_tuple(user_input_scenarios): + """Test plotly parameter with (url, encoding) tuple.""" + plotly_tuple = user_input_scenarios["plotly_with_encoding"] + generator = PageGenerator(plotly=plotly_tuple) + html = generator.generate_index() + scripts = get_scripts_from_html(html) + + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == plotly_tuple[0] # Should be the URL from tuple + assert scripts[2].endswith("kaleido_scopes.js") + + +async def test_mathjax_with_encoding_tuple(user_input_scenarios): + """Test mathjax parameter with (url, encoding) tuple.""" + mathjax_tuple = user_input_scenarios["mathjax_with_encoding"] + generator = PageGenerator(mathjax=mathjax_tuple) + html = generator.generate_index() + scripts = get_scripts_from_html(html) + + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == mathjax_tuple[0] # Should be the URL from tuple + assert scripts[1].endswith("package_data/plotly.min.js") + assert scripts[2].endswith("kaleido_scopes.js") + + +async def test_others_tuple_error(user_input_scenarios): + """Test that others parameter with tuples currently fails (documents bug).""" + # Create a tuple for others list + url_encoding_tuple = user_input_scenarios["plotly_with_encoding"] + + # This should fail until the others parameter properly handles tuples + with pytest.raises((TypeError, AttributeError, ValueError)): + PageGenerator(others=[url_encoding_tuple]) + + +@given(st.text(min_size=1, max_size=10).map(lambda x: f"https://example.com/{x}.js")) +async def test_plotly_urls_hypothesis(url): + """Test plotly with hypothesis-generated URLs.""" + generator = PageGenerator(plotly=url) + html = generator.generate_index() + scripts = get_scripts_from_html(html) + + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == url + assert scripts[2].endswith("kaleido_scopes.js") + + +@given( + st.tuples( + st.text(min_size=1, max_size=10).map(lambda x: f"https://example.com/{x}.js"), + st.sampled_from(["utf-8", "utf-16", "ascii"]), + ), +) +async def test_encoding_tuples_hypothesis(url_encoding_tuple): + """Test encoding tuples with hypothesis-generated data.""" + url, encoding = url_encoding_tuple + + # Test with plotly + generator = PageGenerator(plotly=(url, encoding)) + html = generator.generate_index() + scripts = get_scripts_from_html(html) + + assert len(scripts) == 3 # noqa: PLR2004 + assert scripts[0] == DEFAULT_MATHJAX + assert scripts[1] == url + assert scripts[2].endswith("kaleido_scopes.js") + + # Test with mathjax + generator2 = PageGenerator(mathjax=(url, encoding)) + html2 = generator2.generate_index() + scripts2 = get_scripts_from_html(html2) + + assert len(scripts2) == 3 # noqa: PLR2004 + assert scripts2[0] == url + assert scripts2[1].endswith("package_data/plotly.min.js") + assert scripts2[2].endswith("kaleido_scopes.js") From 2b20a3eba41043896f46225749bde5c5cb074057 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 19:28:23 -0500 Subject: [PATCH 027/167] Fix file path ensuring error. --- src/py/kaleido/_page_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 65f1f98d..846a6f0f 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -20,7 +20,7 @@ def _ensure_path(path: Path | str) -> None: _logger.debug(f"Ensuring path {path!s}") if urlparse(str(path)).scheme.startswith("http"): # is url return - if not Path(path).exists(): + if not Path(urlparse(str(path)).path).exists(): raise FileNotFoundError(f"{path!s} does not exist.") From a71a2025e5a6bf324cf4d9c486d4fd121a123bd2 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 19:28:33 -0500 Subject: [PATCH 028/167] Add hypothesis. --- src/py/pyproject.toml | 1 + src/py/uv.lock | 58 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/py/pyproject.toml b/src/py/pyproject.toml index 1cca5695..080be93f 100644 --- a/src/py/pyproject.toml +++ b/src/py/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "pytest-order>=1.3.0", "pandas>=2.0.3", "typing-extensions>=4.12.2", + "hypothesis>=6.113.0", ] [tool.ruff.lint] diff --git a/src/py/uv.lock b/src/py/uv.lock index 7206f1a3..9bc4dbda 100644 --- a/src/py/uv.lock +++ b/src/py/uv.lock @@ -18,6 +18,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, ] +[[package]] +name = "attrs" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -71,6 +80,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612, upload-time = "2024-04-08T09:04:17.414Z" }, ] +[[package]] +name = "hypothesis" +version = "6.113.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.9'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version < '3.9'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, + { name = "sortedcontainers", marker = "python_full_version < '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/32/6513cd7256f38c19a6c8a1d5ce9792bcd35c7f11651989994731f0e97672/hypothesis-6.113.0.tar.gz", hash = "sha256:5556ac66fdf72a4ccd5d237810f7cf6bdcd00534a4485015ef881af26e20f7c7", size = 408897, upload-time = "2024-10-09T03:51:05.707Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/fa/4acb477b86a94571958bd337eae5baf334d21b8c98a04b594d0dad381ba8/hypothesis-6.113.0-py3-none-any.whl", hash = "sha256:d539180eb2bb71ed28a23dfe94e67c851f9b09f3ccc4125afad43f17e32e2bad", size = 469790, upload-time = "2024-10-09T03:51:02.629Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.138.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "attrs", marker = "python_full_version >= '3.9'" }, + { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "sortedcontainers", marker = "python_full_version >= '3.9'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/28/9aa38d1cf2b00d385926fd44318d2b49948c060969ab29e82e8bb654b16c/hypothesis-6.138.3.tar.gz", hash = "sha256:9bffd1382b99e67c46512dac45ec013bae4b39d3d0ef98f0d87535f06d8efc9e", size = 463165, upload-time = "2025-08-24T07:29:16.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/60/7db4d683d27b24a6dfd8f82ef28332d20b0d99a976ae696569622383c900/hypothesis-6.138.3-py3-none-any.whl", hash = "sha256:19291d3ba478527155c34704b038a21ba86b2f31d36673446f981a67f705b3f4", size = 530081, upload-time = "2025-08-24T07:29:12.862Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -94,6 +140,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "async-timeout" }, + { name = "hypothesis", version = "6.113.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, + { name = "hypothesis", version = "6.138.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "mypy", version = "1.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, { name = "mypy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, { name = "pandas", version = "2.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, @@ -123,6 +171,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "async-timeout" }, + { name = "hypothesis", specifier = ">=6.113.0" }, { name = "mypy", specifier = ">=1.14.1" }, { name = "pandas", specifier = ">=2.0.3" }, { name = "plotly", extras = ["express"], specifier = ">=6.1.1" }, @@ -1247,6 +1296,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "tomli" version = "2.2.1" From 63332ca631774866347ad795f40e995908348f94 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 19:59:58 -0500 Subject: [PATCH 029/167] Allow _ensure_path to deal with str/encoding combos --- src/py/kaleido/_page_generator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 846a6f0f..b82e5a8c 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -16,7 +16,9 @@ KJS_PATH = Path(__file__).resolve().parent / "vendor" / "kaleido_scopes.js" -def _ensure_path(path: Path | str) -> None: +def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: + if isinstance(path, tuple): + path = path[0] _logger.debug(f"Ensuring path {path!s}") if urlparse(str(path)).scheme.startswith("http"): # is url return @@ -130,8 +132,8 @@ def generate_index(self, path=None): script_tag = '\n ' script_tag_charset = '\n ' for script in self._scripts: - if isinstance(script, str): - page += script_tag % script + if isinstance(script, (str, Path)): + page += script_tag % str(script) else: page += script_tag_charset % script page += self.footer From d495e34004b54a51e37104c4f97748ae726841f6 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 20:01:42 -0500 Subject: [PATCH 030/167] Tweak manually hypothesis tests in PageGenerator --- src/py/tests/test_page_generator.py | 259 ++++++++++++---------------- 1 file changed, 111 insertions(+), 148 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index b3e3a588..308aaead 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -43,6 +43,7 @@ class HTMLAnalyzer(HTMLParser): def __init__(self): super().__init__() self.scripts = [] + self.encodings = [] self.boilerplate = [] self._in_script = False @@ -50,6 +51,7 @@ def handle_starttag(self, tag, attrs): if tag == "script" and "src" in (attr_dict := dict(attrs)): self._in_script = True self.scripts.append(attr_dict["src"]) + self.encodings.append(attr_dict.get("charset")) return self.boilerplate.append(self.get_starttag_text()) @@ -98,7 +100,7 @@ def get_scripts_from_html(generated_html): f"Got:\n{generated_boilerplate}" ) - return analyzer.scripts + return analyzer.scripts, analyzer.encodings # Fixtures for user supplied input scenarios @@ -124,45 +126,34 @@ def nonexistent_file_path(): return Path("/nonexistent/path/file.js") -@pytest.fixture -def user_input_scenarios(): - """Fixture for user supplied input scenarios using hypothesis strategies.""" - - # Generate sample data using hypothesis strategies - http_urls = st.text( +_h_url = st.tuples( + st.sampled_from(["s", ""]), + st.text( min_size=1, max_size=20, - alphabet=st.characters(whitelist_categories=("Lu", "Ll", "And")), - ).map(lambda x: f"https://example.com/{x}.js") + alphabet=st.characters(whitelist_categories=("Lu", "Ll")), + ), +).map(lambda x: f"http{x[0]}://example.com/{x[1]}.js") - return { - "custom_plotly_url": http_urls.example(), - "custom_mathjax_url": http_urls.example(), - "other_scripts": [ - http_urls.example(), - http_urls.example(), - ], - "plotly_with_encoding": (http_urls.example(), "utf-8"), - "mathjax_with_encoding": (http_urls.example(), "utf-16"), - } +_h_file_str = st.just(__file__) +_h_file_path = st.just(Path(__file__)) +_h_file_uri = st.just(Path(__file__).as_uri()) +_h_uri = st.one_of(_h_url, _h_file_str, _h_file_path, _h_file_uri) -@pytest.fixture -def hypothesis_urls(): - """Generate hypothesis-based URL strategies.""" - return st.text(min_size=1, max_size=20).map(lambda x: f"https://example.com/{x}.js") +_h_encoding = st.sampled_from(["utf-8", "utf-16", "ascii", "latin1"]) +strategy_valid_path = st.one_of(_h_uri, st.tuples(_h_uri, _h_encoding)) -@pytest.fixture -def hypothesis_encodings(): - """Generate hypothesis-based encoding strategies.""" - return st.sampled_from(["utf-8", "utf-16", "ascii", "latin1"]) - +# Variable length list strategy for 'others' parameter +strategy_others_list = st.lists(strategy_valid_path, min_size=0, max_size=3) -@pytest.fixture -def hypothesis_tuples(hypothesis_urls, hypothesis_encodings): - """Generate hypothesis-based (url, encoding) tuples.""" - return st.tuples(hypothesis_urls, hypothesis_encodings) +# Mathjax strategy (includes None, False, True, and path options) +strategy_mathjax = st.one_of( + st.none(), + st.just(False), # noqa: FBT003 + strategy_valid_path, +) # Test default combinations @@ -185,7 +176,7 @@ async def test_defaults_no_plotly_available(): # Test no imports (plotly not available) no_imports = PageGenerator().generate_index() - scripts = 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 @@ -199,7 +190,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 = 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 @@ -215,7 +206,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 = 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 @@ -227,7 +218,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 = 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") @@ -235,63 +226,116 @@ async def test_mathjax_false(): # Test user overrides -async def test_custom_plotly_url(user_input_scenarios): +@given(strategy_valid_path) # claude, change all further functions to this style +async def test_custom_plotly_url(custom_plotly): """Test custom plotly URL override.""" - custom_plotly = user_input_scenarios["custom_plotly_url"] with_custom = PageGenerator(plotly=custom_plotly).generate_index() - scripts = get_scripts_from_html(with_custom) + scripts, encodings = get_scripts_from_html(with_custom) assert len(scripts) == 3 # noqa: PLR2004 assert scripts[0] == DEFAULT_MATHJAX - assert scripts[1] == custom_plotly + if isinstance(custom_plotly, tuple): + assert scripts[1] == str(custom_plotly[0]) + assert encodings[1] == custom_plotly[1] + else: + assert scripts[1] == str(custom_plotly) assert scripts[2].endswith("kaleido_scopes.js") -async def test_custom_mathjax_url(user_input_scenarios): +@given(strategy_valid_path) +async def test_custom_mathjax_url(custom_mathjax): """Test custom mathjax URL override.""" - custom_mathjax = user_input_scenarios["custom_mathjax_url"] with_custom = PageGenerator(mathjax=custom_mathjax).generate_index() - scripts = get_scripts_from_html(with_custom) + scripts, encodings = get_scripts_from_html(with_custom) assert len(scripts) == 3 # noqa: PLR2004 - assert scripts[0] == custom_mathjax + if isinstance(custom_mathjax, tuple): + assert scripts[0] == str(custom_mathjax[0]) + assert encodings[0] == custom_mathjax[1] + else: + assert scripts[0] == str(custom_mathjax) assert scripts[1].endswith("package_data/plotly.min.js") assert scripts[2].endswith("kaleido_scopes.js") -async def test_other_scripts(user_input_scenarios): +@given(strategy_others_list) +async def test_other_scripts(other_scripts): """Test adding other scripts.""" - other_scripts = user_input_scenarios["other_scripts"] with_others = PageGenerator(others=other_scripts).generate_index() - scripts = get_scripts_from_html(with_others) + scripts, encodings = get_scripts_from_html(with_others) - assert len(scripts) == 5 # noqa: PLR2004 + # mathjax + plotly + others + kaleido_scopes + expected_count = 2 + len(other_scripts) + 1 + assert len(scripts) == expected_count assert scripts[0] == DEFAULT_MATHJAX assert scripts[1].endswith("package_data/plotly.min.js") - assert scripts[2] == other_scripts[0] - assert scripts[3] == other_scripts[1] - assert scripts[4].endswith("kaleido_scopes.js") + # Check all other scripts in order + for i, script in enumerate(other_scripts): + if isinstance(script, tuple): + assert scripts[2 + i] == str(script[0]) + assert encodings[2 + i] == script[1] + else: + assert scripts[2 + i] == str(script) + + assert scripts[-1].endswith("kaleido_scopes.js") -async def test_combined_overrides(user_input_scenarios): - """Test combination of multiple overrides.""" - custom_plotly = user_input_scenarios["custom_plotly_url"] - custom_mathjax = user_input_scenarios["custom_mathjax_url"] - other_scripts = user_input_scenarios["other_scripts"] +@given( + custom_plotly=strategy_valid_path, + custom_mathjax=strategy_mathjax, + other_scripts=strategy_others_list, +) +async def test_combined_overrides(custom_plotly, custom_mathjax, other_scripts): + """Test combination of multiple overrides.""" combined = PageGenerator( plotly=custom_plotly, mathjax=custom_mathjax, others=other_scripts, ).generate_index() - scripts = get_scripts_from_html(combined) - - assert len(scripts) == 5 # noqa: PLR2004 - assert scripts[0] == custom_mathjax - assert scripts[1] == custom_plotly - assert scripts[2] == other_scripts[0] - assert scripts[3] == other_scripts[1] - assert scripts[4].endswith("kaleido_scopes.js") + scripts, encodings = get_scripts_from_html(combined) + + # Calculate expected count + expected_count = 0 + script_index = 0 + + # Mathjax adds one (unless False) + if custom_mathjax is not False: + expected_count += 1 + if custom_mathjax is None: + expected_mathjax = DEFAULT_MATHJAX + elif isinstance(custom_mathjax, tuple): + expected_mathjax = str(custom_mathjax[0]) + assert encodings[script_index] == custom_mathjax[1] + else: + expected_mathjax = str(custom_mathjax) + assert scripts[script_index] == expected_mathjax + script_index += 1 + + # Plotly always adds one + expected_count += 1 + if isinstance(custom_plotly, tuple): + assert scripts[script_index] == str(custom_plotly[0]) + assert encodings[script_index] == custom_plotly[1] + else: + assert scripts[script_index] == str(custom_plotly) + script_index += 1 + + # Others adds however many are in the list + expected_count += len(other_scripts) + for script in other_scripts: + if isinstance(script, tuple): + assert scripts[script_index] == str(script[0]) + assert encodings[script_index] == script[1] + else: + assert scripts[script_index] == str(script) + script_index += 1 + + # Kaleido scopes always adds one + expected_count += 1 + assert scripts[script_index].endswith("kaleido_scopes.js") + + assert len(scripts) == expected_count # Test file path validation @@ -300,7 +344,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 = 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) @@ -309,7 +353,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 = 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() @@ -359,91 +403,10 @@ async def test_http_urls_skip_file_validation(): others=["https://nonexistent.example.com/other.js"], ) html = generator.generate_index() - scripts = 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" assert scripts[1] == "https://nonexistent.example.com/plotly.js" assert scripts[2] == "https://nonexistent.example.com/other.js" assert scripts[3].endswith("kaleido_scopes.js") - - -# Test tuple (path, encoding) functionality -async def test_plotly_with_encoding_tuple(user_input_scenarios): - """Test plotly parameter with (url, encoding) tuple.""" - plotly_tuple = user_input_scenarios["plotly_with_encoding"] - generator = PageGenerator(plotly=plotly_tuple) - html = generator.generate_index() - scripts = get_scripts_from_html(html) - - assert len(scripts) == 3 # noqa: PLR2004 - assert scripts[0] == DEFAULT_MATHJAX - assert scripts[1] == plotly_tuple[0] # Should be the URL from tuple - assert scripts[2].endswith("kaleido_scopes.js") - - -async def test_mathjax_with_encoding_tuple(user_input_scenarios): - """Test mathjax parameter with (url, encoding) tuple.""" - mathjax_tuple = user_input_scenarios["mathjax_with_encoding"] - generator = PageGenerator(mathjax=mathjax_tuple) - html = generator.generate_index() - scripts = get_scripts_from_html(html) - - assert len(scripts) == 3 # noqa: PLR2004 - assert scripts[0] == mathjax_tuple[0] # Should be the URL from tuple - assert scripts[1].endswith("package_data/plotly.min.js") - assert scripts[2].endswith("kaleido_scopes.js") - - -async def test_others_tuple_error(user_input_scenarios): - """Test that others parameter with tuples currently fails (documents bug).""" - # Create a tuple for others list - url_encoding_tuple = user_input_scenarios["plotly_with_encoding"] - - # This should fail until the others parameter properly handles tuples - with pytest.raises((TypeError, AttributeError, ValueError)): - PageGenerator(others=[url_encoding_tuple]) - - -@given(st.text(min_size=1, max_size=10).map(lambda x: f"https://example.com/{x}.js")) -async def test_plotly_urls_hypothesis(url): - """Test plotly with hypothesis-generated URLs.""" - generator = PageGenerator(plotly=url) - html = generator.generate_index() - scripts = get_scripts_from_html(html) - - assert len(scripts) == 3 # noqa: PLR2004 - assert scripts[0] == DEFAULT_MATHJAX - assert scripts[1] == url - assert scripts[2].endswith("kaleido_scopes.js") - - -@given( - st.tuples( - st.text(min_size=1, max_size=10).map(lambda x: f"https://example.com/{x}.js"), - st.sampled_from(["utf-8", "utf-16", "ascii"]), - ), -) -async def test_encoding_tuples_hypothesis(url_encoding_tuple): - """Test encoding tuples with hypothesis-generated data.""" - url, encoding = url_encoding_tuple - - # Test with plotly - generator = PageGenerator(plotly=(url, encoding)) - html = generator.generate_index() - scripts = get_scripts_from_html(html) - - assert len(scripts) == 3 # noqa: PLR2004 - assert scripts[0] == DEFAULT_MATHJAX - assert scripts[1] == url - assert scripts[2].endswith("kaleido_scopes.js") - - # Test with mathjax - generator2 = PageGenerator(mathjax=(url, encoding)) - html2 = generator2.generate_index() - scripts2 = get_scripts_from_html(html2) - - assert len(scripts2) == 3 # noqa: PLR2004 - assert scripts2[0] == url - assert scripts2[1].endswith("package_data/plotly.min.js") - assert scripts2[2].endswith("kaleido_scopes.js") From e92fca892285fe2b6552db48394f1482c6798da8 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 25 Aug 2025 20:09:42 -0500 Subject: [PATCH 031/167] Rename test file --- src/py/tests/{test_calc_fig.py => test_calc_fig_one_off.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/py/tests/{test_calc_fig.py => test_calc_fig_one_off.py} (100%) diff --git a/src/py/tests/test_calc_fig.py b/src/py/tests/test_calc_fig_one_off.py similarity index 100% rename from src/py/tests/test_calc_fig.py rename to src/py/tests/test_calc_fig_one_off.py From 6711e6101257024f9e4f5c85411f2503fa237399 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 10:03:21 -0500 Subject: [PATCH 032/167] Fix bad mathjax conditional --- src/py/kaleido/_page_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index b82e5a8c..69f287a5 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -80,7 +80,7 @@ def __init__( """ self._scripts = [] if mathjax is not False: - if not mathjax: + if not mathjax or mathjax is True: mathjax = DEFAULT_MATHJAX else: _ensure_path(mathjax) From 76ad5950c0f532179266a0f2d270ca23818733f7 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 11:50:18 -0500 Subject: [PATCH 033/167] Add more types to Kaleido --- src/py/kaleido/kaleido.py | 69 +++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 526603b2..2b3c19bb 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -4,7 +4,7 @@ import asyncio import warnings -from collections.abc import Iterable +from collections.abc import AsyncIterable, Iterable from functools import partial from pathlib import Path from typing import TYPE_CHECKING @@ -24,6 +24,8 @@ from types import TracebackType from typing import Any, Callable, Coroutine + from ._fig_tools import Figurish + _logger = logistro.getLogger(__name__) # Show a warning if the installed Plotly version @@ -31,10 +33,10 @@ warn_incompatible_plotly() -def _make_printer(name: str) -> Callable[[str], Coroutine[Any, Any, None]]: +def _make_printer(name: str) -> Callable[[Any], Coroutine[Any, Any, None]]: """Create event printer for generic events. Helper function.""" - async def print_all(response: str) -> None: + async def print_all(response: Any) -> None: _logger.debug2(f"{name}:{response}") return print_all @@ -93,8 +95,8 @@ def __init__( # noqa: D417, PLR0913 no args/kwargs in description page_generator: None | PageGenerator | str | Path = None, n: int = 1, timeout: int | None = 90, - width: int | None = None, - height: int | None = None, + width: int | None = None, # deprecate + height: int | None = None, # deprecate stepper: bool = False, plotlyjs: str | None = None, mathjax: str | None = None, @@ -132,8 +134,8 @@ def __init__( # noqa: D417, PLR0913 no args/kwargs in description page = page_generator self._timeout = timeout self._n = n - self._height = height - self._width = width + self._height = height # deprecate + self._width = width # deprecate self._stepper = stepper self._plotlyjs = plotlyjs self._mathjax = mathjax @@ -177,7 +179,7 @@ def __init__( # noqa: D417, PLR0913 no args/kwargs in description page = PageGenerator(plotly=self._plotlyjs, mathjax=self._mathjax) page.generate_index(index) - async def _conform_tabs(self, tabs=None) -> None: + async def _conform_tabs(self, tabs: list[choreo.Tab] | None = None) -> None: if not tabs: tabs = list(self.tabs.values()) _logger.info(f"Conforming {len(tabs)} to {self._index}") @@ -258,7 +260,7 @@ async def _get_kaleido_tab(self) -> _KaleidoTab: _logger.info(f"Got {tab.tab.target_id[:4]}") return tab - async def _return_kaleido_tab(self, tab): + async def _return_kaleido_tab(self, tab: _KaleidoTab) -> None: """ Refresh tab and put it back into the available queue. @@ -275,7 +277,11 @@ async def _return_kaleido_tab(self, tab): await self.tabs_ready.put(tab) _logger.debug(f"{tab.tab.target_id[:4]} put back.") - def _clean_tab_return_task(self, main_task, task): + def _clean_tab_return_task( + self, + main_task: asyncio.Task, + task: asyncio.Task, + ) -> None: _logger.info("Cleaning out background tasks.") self._background_render_tasks.remove(task) e = task.exception() @@ -285,7 +291,14 @@ def _clean_tab_return_task(self, main_task, task): main_task.cancel() raise e - def _check_render_task(self, name, tab, main_task, error_log, task): + def _check_render_task( + self, + name: str, + tab: _KaleidoTab, + main_task: asyncio.Task, + error_log: None | list[ErrorEntry], + task: asyncio.Task, + ) -> None: if task.cancelled(): _logger.info(f"Something cancelled {name}.") error_log.append( @@ -307,7 +320,13 @@ def _check_render_task(self, name, tab, main_task, error_log, task): self._background_render_tasks.add(t) t.add_done_callback(partial(self._clean_tab_return_task, main_task)) - async def _render_task(self, tab, args, error_log=None, profiler=None): + async def _render_task( + self, + tab: _KaleidoTab, + args: Any, + error_log: None | list[ErrorEntry] = None, + profiler: None | list = None, + ): _logger.info(f"Posting a task for {args['full_path'].name}") if self._timeout: try: @@ -346,11 +365,11 @@ async def _render_task(self, tab, args, error_log=None, profiler=None): async def calc_fig( self, - fig, - path=None, - opts=None, + fig: Figurish, + path: str | Path | None = None, + opts: None | dict = None, *, - topojson=None, + topojson: str | None = None, ): """ Calculate the bytes for a figure. @@ -381,13 +400,13 @@ async def calc_fig( async def write_fig( # noqa: PLR0913, C901 (too many args, complexity) self, - fig, - path=None, - opts=None, + fig: Figurish, + path: str | Path | None = None, + opts: dict | None = None, *, - topojson=None, - error_log=None, - profiler=None, + topojson: str | None = None, + error_log: None | list[ErrorEntry] = None, + profiler: None | list = None, ): """ Call the plotly renderer via javascript on first available tab. @@ -474,10 +493,10 @@ async def _loop(f): async def write_fig_from_object( # noqa: C901 too complex self, - generator, + generator: Iterable | AsyncIterable, *, - error_log=None, - profiler=None, + error_log: None | list[ErrorEntry] = None, + profiler: None | list = None, ): """ Equal to `write_fig` but allows the user to generate all arguments. From cfdacc1386fb99fcb3dc22555980aeec2f75c44b Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 11:56:23 -0500 Subject: [PATCH 034/167] Add check for None value. --- src/py/kaleido/kaleido.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 2b3c19bb..5b3f81bd 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -301,9 +301,10 @@ def _check_render_task( ) -> None: if task.cancelled(): _logger.info(f"Something cancelled {name}.") - error_log.append( - ErrorEntry(name, asyncio.CancelledError, tab.javascript_log), - ) + if error_log: + error_log.append( + ErrorEntry(name, asyncio.CancelledError, tab.javascript_log), + ) elif e := task.exception(): _logger.error(f"Render Task Error In {name}- ", exc_info=e) if isinstance(e, (asyncio.TimeoutError, TimeoutError)) and error_log: From 9fd331c6948d78cd13265dfb54c03fea3dbb31e7 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 12:00:45 -0500 Subject: [PATCH 035/167] Fix kaleido typing errors. --- src/py/kaleido/kaleido.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 5b3f81bd..50099442 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -24,7 +24,7 @@ from types import TracebackType from typing import Any, Callable, Coroutine - from ._fig_tools import Figurish + from . import _fig_tools _logger = logistro.getLogger(__name__) @@ -198,8 +198,9 @@ async def _conform_tabs(self, tabs: list[choreo.Tab] | None = None) -> None: _logger.info("Waiting on all navigates") await asyncio.gather(*tasks) _logger.info("All navigates done, putting them all in queue.") - for tab in kaleido_tabs: - await self.tabs_ready.put(tab) + + for ktab in kaleido_tabs: + await self.tabs_ready.put(ktab) self._total_tabs = len(kaleido_tabs) _logger.debug("Tabs fully navigated/enabled/ready") @@ -366,9 +367,9 @@ async def _render_task( async def calc_fig( self, - fig: Figurish, + fig: _fig_tools.Figurish, path: str | Path | None = None, - opts: None | dict = None, + opts: None | _fig_tools.LayoutOpts = None, *, topojson: str | None = None, ): @@ -399,11 +400,11 @@ async def calc_fig( await self._return_kaleido_tab(tab) return data[0] - async def write_fig( # noqa: PLR0913, C901 (too many args, complexity) + async def write_fig( # noqa: PLR0913, PLR0912, C901 (too many args, complexity) self, - fig: Figurish, + fig: _fig_tools.Figurish, path: str | Path | None = None, - opts: dict | None = None, + opts: _fig_tools.LayoutOpts | None = None, *, topojson: str | None = None, error_log: None | list[ErrorEntry] = None, @@ -440,8 +441,8 @@ async def write_fig( # noqa: PLR0913, C901 (too many args, complexity) else: _logger.debug(f"Is iterable {type(fig)}") - main_task = asyncio.current_task() - self._main_tasks.add(main_task) + if main_task := asyncio.current_task(): + self._main_tasks.add(main_task) tasks = set() async def _loop(f): @@ -490,7 +491,8 @@ async def _loop(f): task.cancel() raise finally: - self._main_tasks.remove(main_task) + if main_task: + self._main_tasks.remove(main_task) async def write_fig_from_object( # noqa: C901 too complex self, @@ -535,8 +537,8 @@ async def write_fig_from_object( # noqa: C901 too complex if profiler is not None: _logger.info("Using profiler.") - main_task = asyncio.current_task() - self._main_tasks.add(main_task) + if main_task := asyncio.current_task(): + self._main_tasks.add(main_task) tasks = set() async def _loop(args): @@ -587,4 +589,5 @@ async def _loop(args): task.cancel() raise finally: - self._main_tasks.remove(main_task) + if main_task: + self._main_tasks.remove(main_task) From e3d5a87e082dc44b517111dcf436d48de86b1ea2 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 13:19:39 -0500 Subject: [PATCH 036/167] Iterate on kaleido.py tests claude --- src/py/.pre-commit-config.yaml | 2 +- src/py/tests/test_kaleido.py | 218 +++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/py/tests/test_kaleido.py diff --git a/src/py/.pre-commit-config.yaml b/src/py/.pre-commit-config.yaml index c0686a7b..5f875d93 100644 --- a/src/py/.pre-commit-config.yaml +++ b/src/py/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: add-trailing-comma - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.8.2 + rev: v0.12.10 hooks: # Run the linter. - id: ruff diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py new file mode 100644 index 00000000..d54c046b --- /dev/null +++ b/src/py/tests/test_kaleido.py @@ -0,0 +1,218 @@ +from unittest.mock import patch + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from kaleido import Kaleido + + +@pytest.fixture +async def simple_figure_with_bytes(tmp_path): + """Create a simple figure with calculated bytes and PNG assertion.""" + import plotly.express as px # noqa: PLC0415 + + fig = px.line(x=[1, 2, 3], y=[1, 2, 3]) + path = tmp_path / "test_figure.png" + + async with Kaleido() as k: + bytes_data = await k.calc_fig( + fig, + opts={"format": "png", "width": 400, "height": 300}, + ) + + # Assert it's a PNG by checking the PNG signature + assert bytes_data[:8] == b"\x89PNG\r\n\x1a\n", "Generated data is not a valid PNG" + + return { + "fig": fig, + "bytes": bytes_data, + "path": path, + "opts": {"format": "png", "width": 400, "height": 300}, + } + + +async def test_write_fig_from_object_sync_generator(simple_figure_with_bytes, tmp_path): + """Test write_fig_from_object with sync generator.""" + + file_paths = [] + + def fig_generator(): + for i in range(2): + path = tmp_path / f"test_sync_{i}.png" + file_paths.append(path) + yield { + "fig": simple_figure_with_bytes["fig"], + "path": path, + "opts": simple_figure_with_bytes["opts"], + } + + async with Kaleido() as k: + await k.write_fig_from_object(fig_generator()) + + # Assert that each created file matches the fixture bytes + for path in file_paths: + assert path.exists(), f"File {path} was not created" + created_bytes = path.read_bytes() + assert created_bytes == simple_figure_with_bytes["bytes"], ( + f"File {path} bytes don't match fixture bytes" + ) + + +async def test_write_fig_from_object_async_generator( + simple_figure_with_bytes, + tmp_path, +): + """Test write_fig_from_object with async generator.""" + + file_paths = [] + + async def fig_async_generator(): + for i in range(2): + path = tmp_path / f"test_async_{i}.png" + file_paths.append(path) + yield { + "fig": simple_figure_with_bytes["fig"], + "path": path, + "opts": simple_figure_with_bytes["opts"], + } + + async with Kaleido() as k: + await k.write_fig_from_object(fig_async_generator()) + + # Assert that each created file matches the fixture bytes + for path in file_paths: + assert path.exists(), f"File {path} was not created" + created_bytes = path.read_bytes() + assert created_bytes == simple_figure_with_bytes["bytes"], ( + f"File {path} bytes don't match fixture bytes" + ) + + +async def test_write_fig_from_object_iterator(simple_figure_with_bytes, tmp_path): + """Test write_fig_from_object with iterator.""" + + fig_list = [] + file_paths = [] + for i in range(2): + path = tmp_path / f"test_iter_{i}.png" + file_paths.append(path) + fig_list.append( + { + "fig": simple_figure_with_bytes["fig"], + "path": path, + "opts": simple_figure_with_bytes["opts"], + }, + ) + + async with Kaleido() as k: + await k.write_fig_from_object(fig_list) + + # Assert that each created file matches the fixture bytes + for path in file_paths: + assert path.exists(), f"File {path} was not created" + created_bytes = path.read_bytes() + assert created_bytes == simple_figure_with_bytes["bytes"], ( + f"File {path} bytes don't match fixture bytes" + ) + + +async def test_write_fig_from_object_bare_dictionary( + simple_figure_with_bytes, + tmp_path, +): + """Test write_fig_from_object with bare dictionary list.""" + + path1 = tmp_path / "test_dict_1.png" + path2 = tmp_path / "test_dict_2.png" + + fig_data = [ + { + "fig": simple_figure_with_bytes["fig"], + "path": path1, + "opts": simple_figure_with_bytes["opts"], + }, + { + "fig": simple_figure_with_bytes["fig"].to_dict(), + "path": path2, + "opts": simple_figure_with_bytes["opts"], + }, + ] + + async with Kaleido() as k: + await k.write_fig_from_object(fig_data) + + # Assert that each created file matches the fixture bytes + for path in [path1, path2]: + assert path.exists(), f"File {path} was not created" + created_bytes = path.read_bytes() + assert created_bytes == simple_figure_with_bytes["bytes"], ( + f"File {path} bytes don't match fixture bytes" + ) + + +@given( + path=st.text( + min_size=1, + max_size=50, + alphabet=st.characters(whitelist_categories=["L", "N"]), + ), + width=st.integers(min_value=100, max_value=2000), + height=st.integers(min_value=100, max_value=2000), + format_type=st.sampled_from(["png", "svg", "pdf", "html"]), + topojson=st.one_of(st.none(), st.text(min_size=1, max_size=20)), +) +async def test_write_fig_argument_passthrough( # noqa: PLR0913 + simple_figure_with_bytes, + tmp_path, + path, + width, + height, + format_type, + topojson, +): + """Test that write_fig properly passes arguments to write_fig_from_object.""" + + test_path = tmp_path / f"{path}.{format_type}" + opts = {"format": format_type, "width": width, "height": height} + + # Mock write_fig_from_object to capture arguments + with patch.object(Kaleido, "write_fig_from_object") as mock_write_fig_from_object: + async with Kaleido() as k: + await k.write_fig( + simple_figure_with_bytes["fig"], + path=test_path, + opts=opts, + topojson=topojson, + ) + + # Verify write_fig_from_object was called + 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 + assert len(args) == 1, "Expected exactly one argument (the generator)" + + generator = args[0] + + # Convert generator to list to inspect its contents + generated_args_list = list(generator) + assert len(generated_args_list) == 1, ( + "Expected generator to yield exactly one item" + ) + + generated_args = generated_args_list[0] + + # Validate that the generated arguments match what we passed to write_fig + assert "fig" in generated_args, "Generated args should contain 'fig'" + assert "path" in generated_args, "Generated args should contain 'path'" + assert "opts" in generated_args, "Generated args should contain 'opts'" + assert "topojson" in generated_args, "Generated args should contain 'topojson'" + + # Check that the values match + assert generated_args["fig"] == simple_figure_with_bytes["fig"], ( + "Figure should match" + ) # this should fail + assert str(generated_args["path"]) == str(test_path), "Path should match" + assert generated_args["opts"] == opts, "Options should match" + assert generated_args["topojson"] == topojson, "Topojson should match" From c8afc8895f25d58de0638f31aa48fe4f50b59ad4 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 14:03:40 -0500 Subject: [PATCH 037/167] Mark test skipped for after refactor. --- src/py/tests/test_kaleido.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index d54c046b..ec1eb938 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -1,7 +1,7 @@ from unittest.mock import patch import pytest -from hypothesis import given +from hypothesis import HealthCheck, Phase, given, settings from hypothesis import strategies as st from kaleido import Kaleido @@ -151,6 +151,19 @@ async def test_write_fig_from_object_bare_dictionary( ) +# In the refactor, all figure generation methods are really just wrappers +# for the most flexible, tested above, generate_fig_from_object. +# So we test that one, and then test to make sure its receiving arguments +# properly for the other tests. + + +# Uncomment these settings after refactor. +# @settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +@settings( + phases=[Phase.generate], + max_examples=1, + suppress_health_check=[HealthCheck.function_scoped_fixture], +) @given( path=st.text( min_size=1, @@ -172,7 +185,7 @@ async def test_write_fig_argument_passthrough( # noqa: PLR0913 topojson, ): """Test that write_fig properly passes arguments to write_fig_from_object.""" - + pytest.skip("Remove this failure line and the comment above after the refactor!") test_path = tmp_path / f"{path}.{format_type}" opts = {"format": format_type, "width": width, "height": height} @@ -212,7 +225,11 @@ async def test_write_fig_argument_passthrough( # noqa: PLR0913 # Check that the values match assert generated_args["fig"] == simple_figure_with_bytes["fig"], ( "Figure should match" - ) # this should fail + ) assert str(generated_args["path"]) == str(test_path), "Path should match" assert generated_args["opts"] == opts, "Options should match" assert generated_args["topojson"] == topojson, "Topojson should match" + + +def test_forcefail(): + pytest.fail("After refactor, remove this test and unskip test above.") From 30eeccac37b2abb9b07c0232360892168702bcc3 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 26 Aug 2025 14:31:41 -0500 Subject: [PATCH 038/167] Add further kaleido tests. --- src/py/tests/test_kaleido.py | 155 +++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index ec1eb938..58020acb 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -1,3 +1,5 @@ +import asyncio +import re from unittest.mock import patch import pytest @@ -233,3 +235,156 @@ async def test_write_fig_argument_passthrough( # noqa: PLR0913 def test_forcefail(): pytest.fail("After refactor, remove this test and unskip test above.") + + +async def test_kaleido_instantiate_no_hang(): + """Test that instantiating Kaleido doesn't hang.""" + _ = Kaleido() + + +async def test_kaleido_instantiate_and_close(): + """Test that instantiating and closing Kaleido works.""" + # Maybe there should be a warning or error when closing without opening? + k = Kaleido() + await k.close() + + +async def test_all_methods_context_and_non_context(simple_figure_with_bytes, tmp_path): + """Test write, write_from_object, and calc with context and non-context.""" + fig = simple_figure_with_bytes["fig"] + opts = simple_figure_with_bytes["opts"] + expected_bytes = simple_figure_with_bytes["bytes"] + + # Test with context manager + async with Kaleido() as k: + # Test calc_fig + calc_bytes = await k.calc_fig(fig, opts=opts) + assert calc_bytes == expected_bytes, "calc_fig bytes don't match fixture" + + # Test write_fig + write_path = tmp_path / "context_write.png" + await k.write_fig(fig, path=write_path, opts=opts) + assert write_path.exists(), "write_fig didn't create file" + write_bytes = write_path.read_bytes() + assert write_bytes == expected_bytes, "write_fig bytes don't match fixture" + + # Test write_fig_from_object + obj_path = tmp_path / "context_obj.png" + await k.write_fig_from_object([{"fig": fig, "path": obj_path, "opts": opts}]) + assert obj_path.exists(), "write_fig_from_object didn't create file" + obj_bytes = obj_path.read_bytes() + assert obj_bytes == expected_bytes, ( + "write_fig_from_object bytes don't match fixture" + ) + + # Test without context manager + k = Kaleido() + await k.open() + try: + # Test calc_fig + calc_bytes = await k.calc_fig(fig, opts=opts) + assert calc_bytes == expected_bytes, ( + "Non-context calc_fig bytes don't match fixture" + ) + + # Test write_fig + write_path2 = tmp_path / "non_context_write.png" + await k.write_fig(fig, path=write_path2, opts=opts) + assert write_path2.exists(), "Non-context write_fig didn't create file" + write_bytes2 = write_path2.read_bytes() + assert write_bytes2 == expected_bytes, ( + "Non-context write_fig bytes don't match fixture" + ) + + # Test write_fig_from_object + obj_path2 = tmp_path / "non_context_obj.png" + await k.write_fig_from_object([{"fig": fig, "path": obj_path2, "opts": opts}]) + assert obj_path2.exists(), ( + "Non-context write_fig_from_object didn't create file" + ) + obj_bytes2 = obj_path2.read_bytes() + assert obj_bytes2 == expected_bytes, ( + "Non-context write_fig_from_object bytes don't match fixture" + ) + finally: + await k.close() + + +@pytest.mark.parametrize("n_tabs", [1, 5, 20]) +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: + # Check the queue size matches expected tabs + assert k.tabs_ready.qsize() == n_tabs, ( + f"Queue size {k.tabs_ready.qsize()} != {n_tabs}" + ) + + # Use devtools protocol to verify tab count + # Send getTargets command directly to Kaleido (which is a Browser/Target) + result = await k.send_command("Target.getTargets") + # Count targets that are pages (not service workers, etc.) + page_targets = [t for t in result["targetInfos"] if t.get("type") == "page"] + assert len(page_targets) >= n_tabs, ( + f"Found {len(page_targets)} page targets, expected at least {n_tabs}" + ) + + +async def test_unreasonable_timeout(simple_figure_with_bytes): + """Test that an unreasonably small timeout actually times out.""" + + fig = simple_figure_with_bytes["fig"] + opts = simple_figure_with_bytes["opts"] + + # Use an infinitely small timeout + async with Kaleido(timeout=0.000001) as k: + with pytest.raises((asyncio.TimeoutError, TimeoutError)): + await k.calc_fig(fig, opts=opts) + + +@pytest.mark.parametrize( + ("plotlyjs", "mathjax"), + [ + ("https://cdn.plot.ly/plotly-latest.min.js", None), + ( + None, + "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.0/es5/tex-chtml.min.js", + ), + ( + "https://cdn.plot.ly/plotly-latest.min.js", + "https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.0/es5/tex-chtml.min.js", + ), + ], +) # THESE STRINGS DON'T ACTUALLY MATTER! +async def test_plotlyjs_mathjax_injection(plotlyjs, mathjax): + """Test that plotlyjs and mathjax URLs are properly injected.""" + + async with Kaleido(plotlyjs=plotlyjs, mathjax=mathjax) as k: + # Get a tab from the public queue to check the page source + tab = await k.tabs_ready.get() + try: + # Get the page source using devtools protocol + result = await tab.tab.send_command( + "Runtime.evaluate", + { + "expression": "document.documentElement.outerHTML", + }, + ) + source = result["result"]["value"] + + if plotlyjs: + # Check if plotlyjs URL is in the source + plotly_pattern = re.escape(plotlyjs) + assert re.search(plotly_pattern, source), ( + f"Plotlyjs URL {plotlyjs} not found in page source" + ) + + if mathjax: + # Check if mathjax URL is in the source + mathjax_pattern = re.escape(mathjax) + assert re.search(mathjax_pattern, source), ( + f"Mathjax URL {mathjax} not found in page source" + ) + + finally: + # Put the tab back in the queue + await k.tabs_ready.put(tab) From 8ca2ae7fde6db4edb8d1d646b8e9bffcf7bca1b3 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 27 Aug 2025 11:41:04 -0500 Subject: [PATCH 039/167] Split context/noncontext tests into two --- src/py/tests/test_kaleido.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index 58020acb..2759cf63 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -249,8 +249,8 @@ async def test_kaleido_instantiate_and_close(): await k.close() -async def test_all_methods_context_and_non_context(simple_figure_with_bytes, tmp_path): - """Test write, write_from_object, and calc with context and non-context.""" +async def test_all_methods_context(simple_figure_with_bytes, tmp_path): + """Test write, write_from_object, and calc with context.""" fig = simple_figure_with_bytes["fig"] opts = simple_figure_with_bytes["opts"] expected_bytes = simple_figure_with_bytes["bytes"] @@ -277,9 +277,15 @@ async def test_all_methods_context_and_non_context(simple_figure_with_bytes, tmp "write_fig_from_object bytes don't match fixture" ) + +async def test_all_methods_non_context(simple_figure_with_bytes, tmp_path): + """Test write, write_from_object, and calc with non-context.""" + fig = simple_figure_with_bytes["fig"] + opts = simple_figure_with_bytes["opts"] + expected_bytes = simple_figure_with_bytes["bytes"] + # Test without context manager - k = Kaleido() - await k.open() + k = await Kaleido() try: # Test calc_fig calc_bytes = await k.calc_fig(fig, opts=opts) @@ -288,15 +294,16 @@ async def test_all_methods_context_and_non_context(simple_figure_with_bytes, tmp ) # Test write_fig + write_path2 = tmp_path / "non_context_write.png" await k.write_fig(fig, path=write_path2, opts=opts) + assert write_path2.exists(), "Non-context write_fig didn't create file" write_bytes2 = write_path2.read_bytes() assert write_bytes2 == expected_bytes, ( "Non-context write_fig bytes don't match fixture" ) - # Test write_fig_from_object obj_path2 = tmp_path / "non_context_obj.png" await k.write_fig_from_object([{"fig": fig, "path": obj_path2, "opts": opts}]) assert obj_path2.exists(), ( @@ -306,6 +313,7 @@ async def test_all_methods_context_and_non_context(simple_figure_with_bytes, tmp assert obj_bytes2 == expected_bytes, ( "Non-context write_fig_from_object bytes don't match fixture" ) + finally: await k.close() From 84629f3bd31af461d9e2884cd9fe5b5467008eb5 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 27 Aug 2025 11:53:05 -0500 Subject: [PATCH 040/167] Close browser before cancelling kaleido tasks. --- src/py/kaleido/kaleido.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 50099442..0c1e50fc 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -65,6 +65,7 @@ class Kaleido(choreo.Browser): async def close(self) -> None: """Close the browser.""" + await super().close() if self._tmp_dir: self._tmp_dir.clean() _logger.info("Cancelling tasks.") @@ -75,7 +76,6 @@ async def close(self) -> None: if not task.done(): task.cancel() _logger.info("Exiting Kaleido/Choreo") - return await super().close() async def __aexit__( self, From 74c87651aff86221890b0ac0d8078239b4a03b53 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 27 Aug 2025 14:04:54 -0500 Subject: [PATCH 041/167] Reorganize so __init__ creates no tmp dir --- src/py/kaleido/kaleido.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 0c1e50fc..d0888ddb 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -159,6 +159,14 @@ def __init__( # noqa: D417, PLR0913 no args/kwargs in description "or `kaleido.get_chrome_sync()`.", ) from ChromeNotFoundError + # do this during open because it requires close + self._saved_page_arg = page + + async def open(self): + """Build temporary file if we need one.""" + page = self._saved_page_arg + del self._saved_page_arg + if isinstance(page, str): if page.startswith(r"file://") and Path(unquote(urlparse(page).path)): self._index = page @@ -178,6 +186,7 @@ def __init__( # noqa: D417, PLR0913 no args/kwargs in description if not page: page = PageGenerator(plotly=self._plotlyjs, mathjax=self._mathjax) page.generate_index(index) + await super().open() async def _conform_tabs(self, tabs: list[choreo.Tab] | None = None) -> None: if not tabs: From d2dd530cd03c8415464e68a512462e1ed7859702 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 27 Aug 2025 14:05:20 -0500 Subject: [PATCH 042/167] Fix test_kaleido tests. --- src/py/tests/test_kaleido.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index 2759cf63..8c98ec6f 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -10,12 +10,11 @@ @pytest.fixture -async def simple_figure_with_bytes(tmp_path): +async def simple_figure_with_bytes(): """Create a simple figure with calculated bytes and PNG assertion.""" import plotly.express as px # noqa: PLC0415 fig = px.line(x=[1, 2, 3], y=[1, 2, 3]) - path = tmp_path / "test_figure.png" async with Kaleido() as k: bytes_data = await k.calc_fig( @@ -29,7 +28,6 @@ async def simple_figure_with_bytes(tmp_path): return { "fig": fig, "bytes": bytes_data, - "path": path, "opts": {"format": "png", "width": 400, "height": 300}, } @@ -331,7 +329,9 @@ async def test_tab_count_verification(n_tabs): # Send getTargets command directly to Kaleido (which is a Browser/Target) result = await k.send_command("Target.getTargets") # Count targets that are pages (not service workers, etc.) - page_targets = [t for t in result["targetInfos"] if t.get("type") == "page"] + page_targets = [ + t for t in result["result"]["targetInfos"] if t.get("type") == "page" + ] assert len(page_targets) >= n_tabs, ( f"Found {len(page_targets)} page targets, expected at least {n_tabs}" ) @@ -377,7 +377,7 @@ async def test_plotlyjs_mathjax_injection(plotlyjs, mathjax): "expression": "document.documentElement.outerHTML", }, ) - source = result["result"]["value"] + source = result["result"]["result"]["value"] if plotlyjs: # Check if plotlyjs URL is in the source From cc97c4187b7e4e93c7fc3a5d0c746ba46f9b2ab6 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 27 Aug 2025 14:11:15 -0500 Subject: [PATCH 043/167] Remove forcefail --- src/py/tests/test_kaleido.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index 8c98ec6f..24a4fe1c 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -231,10 +231,6 @@ async def test_write_fig_argument_passthrough( # noqa: PLR0913 assert generated_args["topojson"] == topojson, "Topojson should match" -def test_forcefail(): - pytest.fail("After refactor, remove this test and unskip test above.") - - async def test_kaleido_instantiate_no_hang(): """Test that instantiating Kaleido doesn't hang.""" _ = Kaleido() From fd1bb62d8fe0565f15ffb9f09f31d15351ca08c9 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Wed, 27 Aug 2025 19:50:45 -0500 Subject: [PATCH 044/167] Type fig_tools. --- src/py/kaleido/_fig_tools.py | 55 +++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/py/kaleido/_fig_tools.py b/src/py/kaleido/_fig_tools.py index fdcc63e5..f0966bb9 100644 --- a/src/py/kaleido/_fig_tools.py +++ b/src/py/kaleido/_fig_tools.py @@ -3,13 +3,18 @@ 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 +22,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 +52,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,18 +73,13 @@ 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 @@ -89,7 +99,7 @@ class Spec(TypedDict): data: Figurish -def to_spec(figure, layout_opts: LayoutOpts) -> Spec: +def to_spec(figure: Figurish, layout_opts: LayoutOpts) -> Spec: # Get figure layout layout = figure.get("layout", {}) @@ -123,7 +133,8 @@ def to_spec(figure, layout_opts: LayoutOpts) -> Spec: } -def _next_filename(path, prefix, ext) -> str: +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"$", From 0adba8fb4c5c8a7548c0c84b2ae1c432a98bae37 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:00:30 -0500 Subject: [PATCH 045/167] Shore up current file detecting for testing. --- src/py/tests/test_page_generator.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 308aaead..91575123 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -135,9 +135,11 @@ def nonexistent_file_path(): ), ).map(lambda x: f"http{x[0]}://example.com/{x[1]}.js") -_h_file_str = st.just(__file__) -_h_file_path = st.just(Path(__file__)) -_h_file_uri = st.just(Path(__file__).as_uri()) +_valid_file_string = str(Path(__file__).resolve()) + +_h_file_str = st.just(_valid_file_string) +_h_file_path = st.just(Path(_valid_file_string)) +_h_file_uri = st.just(Path(_valid_file_string).as_uri()) _h_uri = st.one_of(_h_url, _h_file_str, _h_file_path, _h_file_uri) From a11572399b8125f12537a685980c715ddc0c5484 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:17:00 -0500 Subject: [PATCH 046/167] Use tmp_path not __file__ for valid file. --- src/py/tests/test_page_generator.py | 61 +++++++++++++++++------------ 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 91575123..191777e0 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -135,27 +135,35 @@ def nonexistent_file_path(): ), ).map(lambda x: f"http{x[0]}://example.com/{x[1]}.js") -_valid_file_string = str(Path(__file__).resolve()) -_h_file_str = st.just(_valid_file_string) -_h_file_path = st.just(Path(_valid_file_string)) -_h_file_uri = st.just(Path(_valid_file_string).as_uri()) +def st_valid_path(dir_path: Path): + file_path = dir_path / "foo.foo" + file_path.touch() + _valid_file_string = str(file_path.resolve()) -_h_uri = st.one_of(_h_url, _h_file_str, _h_file_path, _h_file_uri) + _h_file_str = st.just(_valid_file_string) + _h_file_path = st.just(Path(_valid_file_string)) + _h_file_uri = st.just(Path(_valid_file_string).as_uri()) -_h_encoding = st.sampled_from(["utf-8", "utf-16", "ascii", "latin1"]) + _h_uri = st.one_of(_h_url, _h_file_str, _h_file_path, _h_file_uri) + + _h_encoding = st.sampled_from(["utf-8", "utf-16", "ascii", "latin1"]) + + return st.one_of(_h_uri, st.tuples(_h_uri, _h_encoding)) -strategy_valid_path = st.one_of(_h_uri, st.tuples(_h_uri, _h_encoding)) # Variable length list strategy for 'others' parameter -strategy_others_list = st.lists(strategy_valid_path, min_size=0, max_size=3) +def st_others_list(dir_path: Path): + return st.lists(st_valid_path(dir_path), min_size=0, max_size=3) + # Mathjax strategy (includes None, False, True, and path options) -strategy_mathjax = st.one_of( - st.none(), - st.just(False), # noqa: FBT003 - strategy_valid_path, -) +def st_mathjax(dir_path: Path): + return st.one_of( + st.none(), + st.just(False), # noqa: FBT003 + st_valid_path(dir_path), + ) # Test default combinations @@ -228,9 +236,10 @@ async def test_mathjax_false(): # Test user overrides -@given(strategy_valid_path) # claude, change all further functions to this style -async def test_custom_plotly_url(custom_plotly): +@given(st.data()) # claude, change all further functions to this style +async def test_custom_plotly_url(tmp_path, data): """Test custom plotly URL override.""" + custom_plotly = data.draw(st_valid_path(tmp_path)) with_custom = PageGenerator(plotly=custom_plotly).generate_index() scripts, encodings = get_scripts_from_html(with_custom) @@ -244,9 +253,10 @@ async def test_custom_plotly_url(custom_plotly): assert scripts[2].endswith("kaleido_scopes.js") -@given(strategy_valid_path) -async def test_custom_mathjax_url(custom_mathjax): +@given(st.data()) +async def test_custom_mathjax_url(tmp_path, data): """Test custom mathjax URL override.""" + custom_mathjax = data.draw(st_valid_path(tmp_path)) with_custom = PageGenerator(mathjax=custom_mathjax).generate_index() scripts, encodings = get_scripts_from_html(with_custom) @@ -260,9 +270,10 @@ async def test_custom_mathjax_url(custom_mathjax): assert scripts[2].endswith("kaleido_scopes.js") -@given(strategy_others_list) -async def test_other_scripts(other_scripts): +@given(st.data()) +async def test_other_scripts(tmp_path, data): """Test adding other scripts.""" + other_scripts = data.draw(st_others_list(tmp_path)) with_others = PageGenerator(others=other_scripts).generate_index() scripts, encodings = get_scripts_from_html(with_others) @@ -283,13 +294,13 @@ async def test_other_scripts(other_scripts): assert scripts[-1].endswith("kaleido_scopes.js") -@given( - custom_plotly=strategy_valid_path, - custom_mathjax=strategy_mathjax, - other_scripts=strategy_others_list, -) -async def test_combined_overrides(custom_plotly, custom_mathjax, other_scripts): +@given(st.data()) +async def test_combined_overrides(tmp_path, data): """Test combination of multiple overrides.""" + custom_plotly = data.draw(st_valid_path(tmp_path)) + custom_mathjax = data.draw(st_mathjax(tmp_path)) + other_scripts = data.draw(st_others_list(tmp_path)) + combined = PageGenerator( plotly=custom_plotly, mathjax=custom_mathjax, From 6f281f8090ea798434728345e3568dfe1b4553d8 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:24:46 -0500 Subject: [PATCH 047/167] Supress unhelpful hypo health checks. --- src/py/tests/test_page_generator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 191777e0..5069f71c 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -7,7 +7,7 @@ import logistro import pytest -from hypothesis import given +from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from kaleido import PageGenerator @@ -236,7 +236,8 @@ async def test_mathjax_false(): # Test user overrides -@given(st.data()) # claude, change all further functions to this style +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) +@given(st.data()) async def test_custom_plotly_url(tmp_path, data): """Test custom plotly URL override.""" custom_plotly = data.draw(st_valid_path(tmp_path)) @@ -253,6 +254,7 @@ async def test_custom_plotly_url(tmp_path, data): assert scripts[2].endswith("kaleido_scopes.js") +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) @given(st.data()) async def test_custom_mathjax_url(tmp_path, data): """Test custom mathjax URL override.""" @@ -270,6 +272,7 @@ async def test_custom_mathjax_url(tmp_path, data): assert scripts[2].endswith("kaleido_scopes.js") +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) @given(st.data()) async def test_other_scripts(tmp_path, data): """Test adding other scripts.""" @@ -294,6 +297,7 @@ async def test_other_scripts(tmp_path, data): assert scripts[-1].endswith("kaleido_scopes.js") +@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]) @given(st.data()) async def test_combined_overrides(tmp_path, data): """Test combination of multiple overrides.""" From be01fdbf98f537d5b3b970ff37a2b0cd97da6bc9 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:29:49 -0500 Subject: [PATCH 048/167] Add faster assert to prove file existence --- src/py/tests/test_page_generator.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 5069f71c..0f2976c8 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -126,25 +126,25 @@ def nonexistent_file_path(): return Path("/nonexistent/path/file.js") -_h_url = st.tuples( - st.sampled_from(["s", ""]), - st.text( - min_size=1, - max_size=20, - alphabet=st.characters(whitelist_categories=("Lu", "Ll")), - ), -).map(lambda x: f"http{x[0]}://example.com/{x[1]}.js") - - def st_valid_path(dir_path: Path): file_path = dir_path / "foo.foo" file_path.touch() + assert file_path.resolve().exists() _valid_file_string = str(file_path.resolve()) _h_file_str = st.just(_valid_file_string) _h_file_path = st.just(Path(_valid_file_string)) _h_file_uri = st.just(Path(_valid_file_string).as_uri()) + _h_url = st.tuples( + st.sampled_from(["s", ""]), + st.text( + min_size=1, + max_size=20, + alphabet=st.characters(whitelist_categories=("Lu", "Ll")), + ), + ).map(lambda x: f"http{x[0]}://example.com/{x[1]}.js") + _h_uri = st.one_of(_h_url, _h_file_str, _h_file_path, _h_file_uri) _h_encoding = st.sampled_from(["utf-8", "utf-16", "ascii", "latin1"]) From 0bca6f7887036d11023599145162d915ec410c80 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:33:48 -0500 Subject: [PATCH 049/167] Add additional url parsing tool. --- src/py/kaleido/_page_generator.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 69f287a5..0f8b1378 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -2,6 +2,7 @@ from pathlib import Path from urllib.parse import urlparse +from urllib.request import url2pathname import logistro @@ -22,7 +23,13 @@ def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: _logger.debug(f"Ensuring path {path!s}") if urlparse(str(path)).scheme.startswith("http"): # is url return - if not Path(urlparse(str(path)).path).exists(): + if not Path( + url2pathname( + urlparse( + str(path), + ).path, + ), + ).exists(): raise FileNotFoundError(f"{path!s} does not exist.") From a76fbadc7eadd973f0d5ffe909d0cdea8e8ff91d Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:38:28 -0500 Subject: [PATCH 050/167] Make special fixture for non existent file URI: Had been generating this automatically which is unreasonable because it doesn't actually exist and the tools to change format automatically need it to --- src/py/tests/test_page_generator.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 0f2976c8..28842183 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -120,6 +120,12 @@ def existing_file_path(): return Path(__file__) +@pytest.fixture +def nonexistent_file_uri(): + """Return path to file that doesn't exist.""" + return Path("file:///nonexistent/path/file.js") + + @pytest.fixture def nonexistent_file_path(): """Return path to file that doesn't exist.""" @@ -377,7 +383,10 @@ async def test_existing_file_path(temp_js_file): assert scripts_uri[2].endswith("kaleido_scopes.js") -async def test_nonexistent_file_path_raises_error(nonexistent_file_path): +async def test_nonexistent_file_path_raises_error( + nonexistent_file_path, + nonexistent_file_uri, +): """Test that nonexistent file paths raise FileNotFoundError.""" # Test with regular path with pytest.raises(FileNotFoundError): @@ -385,10 +394,13 @@ async def test_nonexistent_file_path_raises_error(nonexistent_file_path): # Test with file:/// protocol with pytest.raises(FileNotFoundError): - PageGenerator(plotly=nonexistent_file_path.as_uri()) + PageGenerator(plotly=nonexistent_file_uri()) -async def test_mathjax_nonexistent_file_raises_error(nonexistent_file_path): +async def test_mathjax_nonexistent_file_raises_error( + nonexistent_file_path, + nonexistent_file_uri, +): """Test that nonexistent mathjax file raises FileNotFoundError.""" # Test with regular path with pytest.raises(FileNotFoundError): @@ -396,10 +408,13 @@ async def test_mathjax_nonexistent_file_raises_error(nonexistent_file_path): # Test with file:/// protocol with pytest.raises(FileNotFoundError): - PageGenerator(mathjax=nonexistent_file_path.as_uri()) + PageGenerator(mathjax=nonexistent_file_uri()) -async def test_others_nonexistent_file_raises_error(nonexistent_file_path): +async def test_others_nonexistent_file_raises_error( + nonexistent_file_path, + nonexistent_file_uri, +): """Test that nonexistent file in others list raises FileNotFoundError.""" # Test with regular path with pytest.raises(FileNotFoundError): @@ -407,7 +422,7 @@ async def test_others_nonexistent_file_raises_error(nonexistent_file_path): # Test with file:/// protocol with pytest.raises(FileNotFoundError): - PageGenerator(others=[nonexistent_file_path.as_uri()]) + PageGenerator(others=[nonexistent_file_uri]) # Test HTTP URLs (should not raise FileNotFoundError) From 719a7fdbd8d4e2cb23dda65aa7fa5759801c5bd2 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:50:42 -0500 Subject: [PATCH 051/167] Fix bad path definition --- src/py/tests/test_page_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index 28842183..b5621733 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -123,7 +123,7 @@ def existing_file_path(): @pytest.fixture def nonexistent_file_uri(): """Return path to file that doesn't exist.""" - return Path("file:///nonexistent/path/file.js") + return "file:///nonexistent/path/file.js" @pytest.fixture From 02a1f80041fc170b18f3336d1f140c375f7fddfa Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 11:54:18 -0500 Subject: [PATCH 052/167] Change function to str, so don't call --- src/py/tests/test_page_generator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index b5621733..cfdc241b 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -394,7 +394,7 @@ async def test_nonexistent_file_path_raises_error( # Test with file:/// protocol with pytest.raises(FileNotFoundError): - PageGenerator(plotly=nonexistent_file_uri()) + PageGenerator(plotly=nonexistent_file_uri) async def test_mathjax_nonexistent_file_raises_error( @@ -408,7 +408,7 @@ async def test_mathjax_nonexistent_file_raises_error( # Test with file:/// protocol with pytest.raises(FileNotFoundError): - PageGenerator(mathjax=nonexistent_file_uri()) + PageGenerator(mathjax=nonexistent_file_uri) async def test_others_nonexistent_file_raises_error( From cfb4effc9133f2d64e5281ff7214801e10250353 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:06:00 -0500 Subject: [PATCH 053/167] Be more explicit in path parsing. --- src/py/kaleido/_page_generator.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 0f8b1378..cf7ff6e4 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -21,15 +21,15 @@ def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: if isinstance(path, tuple): path = path[0] _logger.debug(f"Ensuring path {path!s}") - if urlparse(str(path)).scheme.startswith("http"): # is url + parsed = urlparse(str(path)) + if parsed.scheme.startswith("http"): # is url return - if not Path( - url2pathname( - urlparse( - str(path), - ).path, - ), - ).exists(): + elif ( + parsed.scheme.startswith("file") + and not Path(url2pathname(parsed.path)).exists() + ): + raise FileNotFoundError(f"{path!s} does not exist.") + if not Path(path): raise FileNotFoundError(f"{path!s} does not exist.") From f9e7f6497fbe322448d21e13f0cd03e07f84553f Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:10:46 -0500 Subject: [PATCH 054/167] Add missing function. --- src/py/kaleido/_page_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index cf7ff6e4..704f5597 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -29,7 +29,7 @@ def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: and not Path(url2pathname(parsed.path)).exists() ): raise FileNotFoundError(f"{path!s} does not exist.") - if not Path(path): + if not Path(path).exists(): raise FileNotFoundError(f"{path!s} does not exist.") From 69294ed8508d407853b286565ec79d44b3762dc8 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:16:17 -0500 Subject: [PATCH 055/167] Add logging. --- src/py/kaleido/_page_generator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 704f5597..9c991e71 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -26,8 +26,9 @@ def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: return elif ( parsed.scheme.startswith("file") - and not Path(url2pathname(parsed.path)).exists() + and not (_p := Path(url2pathname(parsed.path))).exists() ): + _logger.error(f"File parsed to: {_p}") raise FileNotFoundError(f"{path!s} does not exist.") if not Path(path).exists(): raise FileNotFoundError(f"{path!s} does not exist.") From 5407965e6752c5abe5fdbe91c1fa6d7deddcb8cf Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:23:13 -0500 Subject: [PATCH 056/167] Add yet more logging. --- src/py/kaleido/_page_generator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 9c991e71..676dd7a4 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -22,6 +22,7 @@ def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: path = path[0] _logger.debug(f"Ensuring path {path!s}") parsed = urlparse(str(path)) + _logger.debug(f"Parsed file path: {parsed}") if parsed.scheme.startswith("http"): # is url return elif ( From 33ead6dba5efa9684c343e752cb2024d188d4bb3 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:31:29 -0500 Subject: [PATCH 057/167] Fix bad logic. --- src/py/kaleido/_page_generator.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 676dd7a4..156853c1 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -25,14 +25,13 @@ def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: _logger.debug(f"Parsed file path: {parsed}") if parsed.scheme.startswith("http"): # is url return - elif ( - parsed.scheme.startswith("file") - and not (_p := Path(url2pathname(parsed.path))).exists() - ): + elif parsed.scheme.startswith("file"): + if (_p := Path(url2pathname(parsed.path))).exists(): + return _logger.error(f"File parsed to: {_p}") - raise FileNotFoundError(f"{path!s} does not exist.") - if not Path(path).exists(): - raise FileNotFoundError(f"{path!s} does not exist.") + elif Path(path).exists(): + return + raise FileNotFoundError(f"{path!s} does not exist.") class PageGenerator: From 449282bfca6e8ee8f43d5f464660087007ec52be Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:47:47 -0500 Subject: [PATCH 058/167] Tone down unreasonable 20 processor test. --- src/py/tests/test_kaleido.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index 24a4fe1c..6c83dbab 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -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, 5, 20]) +@pytest.mark.parametrize("n_tabs", [1, 3, 7]) 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: From 62fcefea0f26a7cee94538c57b01c6cdaa43b2ad Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 12:55:42 -0500 Subject: [PATCH 059/167] Remove hypo deadlines for slow CI runners. --- src/py/tests/conftest.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/py/tests/conftest.py b/src/py/tests/conftest.py index 501f3377..8b298cef 100644 --- a/src/py/tests/conftest.py +++ b/src/py/tests/conftest.py @@ -1,10 +1,19 @@ import logging +import os +import platform import logistro import pytest +from hypothesis import HealthCheck, settings _logger = logistro.getLogger(__name__) +settings.register_profile( + "ci", + deadline=None, # no per-example deadline + suppress_health_check=(HealthCheck.too_slow,), # avoid flaky "too slow" on CI +) + # pytest shuts down its capture before logging/threads finish @pytest.fixture(scope="session", autouse=True) @@ -19,3 +28,8 @@ def cleanup_logging_handlers(request): handler.flush() if isinstance(handler, logging.StreamHandler): logging.root.removeHandler(handler) + + +is_ci = os.getenv("GITHUB_ACTIONS") == "true" or os.getenv("CI") == "true" +if is_ci and platform.system in {"Windows", "Darwin"}: + settings.load_profile("ci") From 501fb806fb7f8818a7b2facddff9c5f151cf3d42 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 16:55:52 -0500 Subject: [PATCH 060/167] Organize a bit conftest.py --- src/py/tests/conftest.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/py/tests/conftest.py b/src/py/tests/conftest.py index 8b298cef..8d4f32fd 100644 --- a/src/py/tests/conftest.py +++ b/src/py/tests/conftest.py @@ -8,12 +8,21 @@ _logger = logistro.getLogger(__name__) +# we turn off deadlines for macs and windows in CI +# i'm under the impression that they are resource limited +# and delays are often caused by sharing resources with other +# virtualization neighbors. + settings.register_profile( "ci", deadline=None, # no per-example deadline suppress_health_check=(HealthCheck.too_slow,), # avoid flaky "too slow" on CI ) +is_ci = os.getenv("GITHUB_ACTIONS") == "true" or os.getenv("CI") == "true" +if is_ci and platform.system in {"Windows", "Darwin"}: + settings.load_profile("ci") + # pytest shuts down its capture before logging/threads finish @pytest.fixture(scope="session", autouse=True) @@ -28,8 +37,3 @@ def cleanup_logging_handlers(request): handler.flush() if isinstance(handler, logging.StreamHandler): logging.root.removeHandler(handler) - - -is_ci = os.getenv("GITHUB_ACTIONS") == "true" or os.getenv("CI") == "true" -if is_ci and platform.system in {"Windows", "Darwin"}: - settings.load_profile("ci") From 01952f445dcbe2500e5c5a7a0883b756504497fc Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 20:11:52 -0500 Subject: [PATCH 061/167] Organize fig_tools a bit. --- src/py/kaleido/_fig_tools.py | 76 ++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/src/py/kaleido/_fig_tools.py b/src/py/kaleido/_fig_tools.py index f0966bb9..ab7d111d 100644 --- a/src/py/kaleido/_fig_tools.py +++ b/src/py/kaleido/_fig_tools.py @@ -1,3 +1,9 @@ +""" +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 @@ -82,7 +88,7 @@ def _get_format(extension: str) -> FormatString: 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 @@ -90,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 @@ -99,6 +106,7 @@ class Spec(TypedDict): data: Figurish +# 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", {}) @@ -133,6 +141,7 @@ def to_spec(figure: Figurish, layout_opts: LayoutOpts) -> Spec: } +# 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 @@ -150,36 +159,12 @@ def _next_filename(path: Path | str, prefix: str, ext: str) -> str: return f"{prefix}.{ext}" if n == 1 else f"{prefix}-{n}.{ext}" -def build_fig_spec( # noqa: C901, PLR0912 - 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"] - +# validate and build full route if needed: +def _build_full_path(path, fig, ext): 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()): @@ -192,6 +177,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") @@ -202,4 +188,34 @@ def build_fig_spec( # noqa: C901, PLR0912 name = _next_filename(directory, prefix, ext) full_path = directory / name + +# 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 + + full_path = _build_full_path(path, fig, ext) + + spec = to_spec(fig, opts) + return spec, full_path From d69d1b2bc354e7b05410aa4fb14f0bb445a4aa3e Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 20:24:44 -0500 Subject: [PATCH 062/167] Fix order --- src/py/kaleido/_fig_tools.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/py/kaleido/_fig_tools.py b/src/py/kaleido/_fig_tools.py index ab7d111d..30701808 100644 --- a/src/py/kaleido/_fig_tools.py +++ b/src/py/kaleido/_fig_tools.py @@ -125,6 +125,7 @@ def to_spec(figure: Figurish, 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"), @@ -160,7 +161,11 @@ def _next_filename(path: Path | str, prefix: str, ext: str) -> str: # validate and build full route if needed: -def _build_full_path(path, fig, ext): +def _build_full_path( + path: Path | None, + fig: Figurish, + ext: FormatString, +) -> Path: full_path: Path | None = None directory: Path @@ -187,6 +192,7 @@ def _build_full_path(path, fig, ext): _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 @@ -214,8 +220,8 @@ def build_fig_spec( if _assert_format(ext): # not strict necessary if but helps typeguard opts["format"] = ext - full_path = _build_full_path(path, fig, ext) - spec = to_spec(fig, opts) + full_path = _build_full_path(path, fig, spec["format"]) + return spec, full_path From f65f0ecc15ef1158954db5a3169961535c32a386 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Thu, 28 Aug 2025 20:44:54 -0500 Subject: [PATCH 063/167] Readd figtools tests. --- src/py/tests/test_fig_tools.py | 133 +++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/py/tests/test_fig_tools.py diff --git a/src/py/tests/test_fig_tools.py b/src/py/tests/test_fig_tools.py new file mode 100644 index 00000000..14c53c6a --- /dev/null +++ b/src/py/tests/test_fig_tools.py @@ -0,0 +1,133 @@ +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 From 336f479b087dd5130bcf61dba877df08cbbf29e9 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 12:25:03 -0500 Subject: [PATCH 064/167] Iterate on claude tests. --- src/py/tests/test_fig_tools.py | 90 ++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src/py/tests/test_fig_tools.py b/src/py/tests/test_fig_tools.py index 14c53c6a..730898c4 100644 --- a/src/py/tests/test_fig_tools.py +++ b/src/py/tests/test_fig_tools.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pytest from kaleido import _fig_tools @@ -131,3 +133,91 @@ def test_next_filename_only_numbered_files(tmp_path): 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_withlotsof_symbols", + ), # 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 == Path().cwd() + 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="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="Cannot reach path .* Are all directories created?", + ): + _fig_tools._build_full_path(file_path, fig_dict, "ext") # noqa: SLF001 From c1a2f9945ebfe40037ddd225fffcc8b99c8f274e Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 12:27:44 -0500 Subject: [PATCH 065/167] All tests pass --- src/py/tests/test_fig_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/tests/test_fig_tools.py b/src/py/tests/test_fig_tools.py index 730898c4..135a7c89 100644 --- a/src/py/tests/test_fig_tools.py +++ b/src/py/tests/test_fig_tools.py @@ -144,7 +144,7 @@ def test_next_filename_only_numbered_files(tmp_path): "title": {"text": "My-Test!@#$%^&*()Chart_with[lots]of{symbols}"}, }, }, - "My_TestChart_withlotsof_symbols", + "My_TestChart_withlotsofsymbols", ), # Complex title ( {"layout": {"title": {"text": "Simple Title"}}}, @@ -164,7 +164,7 @@ def test_build_full_path_no_path_input(fig_fixture): result = _fig_tools._build_full_path(None, fig_dict, "ext") # noqa: SLF001 # Should use current directory - assert result.parent == Path().cwd() + assert result.parent.resolve() == Path().cwd().resolve() assert result.parent.is_dir() assert result.name == f"{expected_prefix}.ext" From b8f7185195491ba05c4754e6ff372daee74c95a1 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 13:26:43 -0500 Subject: [PATCH 066/167] Clear up mathjax logic. --- src/py/kaleido/_page_generator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 156853c1..87613bac 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -64,7 +64,7 @@ class PageGenerator: """ """The footer is the HTML that always goes on the bottom. Rarely needs changing.""" - def __init__( + def __init__( # noqa: C901 self, *, plotly: None | Path | str | tuple[Path | str, str] = None, @@ -88,9 +88,9 @@ def __init__( """ self._scripts = [] if mathjax is not False: - if not mathjax or mathjax is True: + if mathjax is None or mathjax is True: mathjax = DEFAULT_MATHJAX - else: + elif mathjax: _ensure_path(mathjax) self._scripts.append(mathjax) if force_cdn: From 970f8b825e3fb11fb81fc568b63acd34fe71b5f7 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 13:35:30 -0500 Subject: [PATCH 067/167] Test Path() as well as str() in filenotfound tests. --- src/py/tests/test_page_generator.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index cfdc241b..e213d6a9 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -392,6 +392,9 @@ async def test_nonexistent_file_path_raises_error( with pytest.raises(FileNotFoundError): PageGenerator(plotly=str(nonexistent_file_path)) + with pytest.raises(FileNotFoundError): + PageGenerator(plotly=Path(nonexistent_file_path)) + # Test with file:/// protocol with pytest.raises(FileNotFoundError): PageGenerator(plotly=nonexistent_file_uri) @@ -406,6 +409,9 @@ async def test_mathjax_nonexistent_file_raises_error( with pytest.raises(FileNotFoundError): PageGenerator(mathjax=str(nonexistent_file_path)) + with pytest.raises(FileNotFoundError): + PageGenerator(mathjax=nonexistent_file_path) + # Test with file:/// protocol with pytest.raises(FileNotFoundError): PageGenerator(mathjax=nonexistent_file_uri) @@ -420,6 +426,9 @@ async def test_others_nonexistent_file_raises_error( with pytest.raises(FileNotFoundError): PageGenerator(others=[str(nonexistent_file_path)]) + with pytest.raises(FileNotFoundError): + PageGenerator(others=[nonexistent_file_path]) + # Test with file:/// protocol with pytest.raises(FileNotFoundError): PageGenerator(others=[nonexistent_file_uri]) From 7c7073771a798a1a68b5b8dc846b3f1edf5f2663 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 14:02:18 -0500 Subject: [PATCH 068/167] Fix to properly validate Path() types --- src/py/kaleido/_page_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 87613bac..34b4064e 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -118,7 +118,7 @@ def __init__( # noqa: C901 except ImportError: _logger.info("Plotly not installed. Using CDN.") plotly = (DEFAULT_PLOTLY, "utf-8") - elif isinstance(plotly, str): + elif isinstance(plotly, (str, Path)): _ensure_path(plotly) plotly = (plotly, "utf-8") _logger.debug(f"Plotly script: {plotly}") From 9e9b9b38e604b74fcd33173df297869518a1ce08 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 15:01:13 -0500 Subject: [PATCH 069/167] Add tests for test_sync_server. --- src/py/tests/test_sync_server.py | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/py/tests/test_sync_server.py 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) From 88533f1b9f604e1c55315e8dd20bcae648d1709c Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Fri, 29 Aug 2025 15:05:42 -0500 Subject: [PATCH 070/167] Fix CHANGELOG.txt --- src/py/CHANGELOG.txt | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From 7dceec2649f5235c31f175bb2fedd934e5f42598 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 17:23:36 -0500 Subject: [PATCH 071/167] Do basic type fixing. --- src/py/kaleido/kaleido.py | 49 +++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index fffae8b3..69d1028b 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable, Iterable, TypedDict +from collections.abc import AsyncIterable, Iterable from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from urllib.parse import unquote, urlparse import choreographer as choreo @@ -26,13 +26,18 @@ if TYPE_CHECKING: from types import TracebackType - from typing import Any, Union + from typing import Any, List, Tuple, TypeVar, Union, ValuesView - from typing_extension import NotRequired, Required + from typing_extensions import NotRequired, Required, TypeAlias from . import _fig_tools - AnyIterable = Union[Iterable, AsyncIterable] # not runtime + T = TypeVar("T") + AnyIterable: TypeAlias = Union[Iterable[T], AsyncIterable[T]] # not runtime + + # union of sized iterables since 3.8 doesn't have & operator + # Iterable & Sized + Listish: TypeAlias = Union[Tuple[T], List[T], ValuesView[T]] class Kaleido(choreo.Browser): @@ -112,13 +117,13 @@ async def open(self): page.generate_index(index) await super().open() - async def _conform_tabs(self, tabs: list[choreo.Tab] | None = None) -> None: - _logger.info(f"Conforming {len(tabs)} to {self._index}") + async def _conform_tabs(self, tabs: Listish[choreo.Tab] | None = None) -> None: if not tabs: - tabs = list(self.tabs.values()) + tabs = self.tabs.values() + _logger.info(f"Conforming {len(tabs)} to {self._index}") for i, tab in enumerate(tabs): _logger.debug2(f"Subscribing * to tab: {tab}.") - tab.subscribe("*", _utils.make_printer(f"tab-{i!s} event")) + tab.subscribe("*", _utils.event_printer(f"tab-{i!s}: Event Dump:")) kaleido_tabs = [_KaleidoTab(tab, _stepper=self._stepper) for tab in tabs] @@ -136,7 +141,7 @@ async def populate_targets(self) -> None: once ever per object. """ await super().populate_targets() - await self._conform_tabs(self.tabs.values()) + await self._conform_tabs() needed_tabs = self._n - len(self.tabs) if not needed_tabs: return @@ -209,35 +214,36 @@ async def _return_kaleido_tab(self, tab: _KaleidoTab) -> None: #### WE'RE HERE - async def _create_render_task( + def _create_render_task( self, tab: _KaleidoTab, - *args: Any, + spec: _fig_tools.Spec, + full_path: Path, **kwargs: Any, ) -> asyncio.Task: - _logger.info(f"Posting a task for {args['full_path'].name}") - t = asyncio.create_task( + _logger.info(f"Posting a task for {full_path.name}") + t: asyncio.Task = asyncio.create_task( asyncio.wait_for( tab._write_fig( # noqa: SLF001 - *args, + spec, + full_path, **kwargs, ), self._timeout, ), ) - # create_task_log_error is a create_task helper, set and forget # to create a task and add a post-run action which checks for # errors and logs them. this pattern is the best i've found # but somehow its still pretty distressing t.add_done_callback( - lambda: self._tab_requeue_tasks.add( + lambda _f: self._tab_requeue_tasks.add( _utils.create_task_log_error( self._return_kaleido_tab(tab), ), ), ) - _logger.info(f"Posted task ending for {args['full_path'].name}") + _logger.info(f"Posted task ending for {full_path.name}") return t ### API ### @@ -245,7 +251,7 @@ async def _create_render_task( class FigureGenerator(TypedDict): fig: Required[_fig_tools.Figurish] path: NotRequired[None | str | Path] - opts: NotRequired[_fig_tools.LayoutOpts, None] + opts: NotRequired[_fig_tools.LayoutOpts | None] # also write_fig_from_dict async def write_fig_from_object( @@ -257,7 +263,7 @@ async def write_fig_from_object( """Temp.""" if main_task := asyncio.current_task(): self._main_render_coroutines.add(main_task) - tasks = set() + tasks: set[asyncio.Task] = set() try: async for args in _utils.ensure_async_iter(generator): @@ -270,13 +276,12 @@ async def write_fig_from_object( tab = await self._get_kaleido_tab() - t = self._create_render_task( + t: asyncio.Task = self._create_render_task( tab, spec, full_path, topojson=topojson, ) - tasks.add(t) await asyncio.gather(*tasks, return_exceptions=not cancel_on_error) From 71460e9689722678ed92b0d543b6b6384a503fcd Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 17:36:49 -0500 Subject: [PATCH 072/167] Add more types. --- src/py/kaleido/kaleido.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 69d1028b..1be6331e 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -252,6 +252,7 @@ class FigureGenerator(TypedDict): fig: Required[_fig_tools.Figurish] path: NotRequired[None | str | Path] opts: NotRequired[_fig_tools.LayoutOpts | None] + topojson: NotRequired[None | str] # also write_fig_from_dict async def write_fig_from_object( @@ -296,11 +297,11 @@ async def write_fig_from_object( async def write_fig( self, - fig, - path=None, - opts=None, + fig: _fig_tools.Figurish, + path: None | Path | str = None, + opts: None | _fig_tools.LayoutOpts = None, *, - topojson=None, + topojson: str | None = None, ) -> None: """Temp.""" if _is_figurish(fig) or not isinstance(fig, (Iterable, AsyncIterable)): From 17a0546cdcef3530ef6b73537e643aa873877500 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 17:53:21 -0500 Subject: [PATCH 073/167] Connect types between __init__ and kaledio.py --- src/py/kaleido/__init__.py | 4 +++- src/py/kaleido/kaleido.py | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/py/kaleido/__init__.py b/src/py/kaleido/__init__.py index 71c18427..0936f0db 100644 --- a/src/py/kaleido/__init__.py +++ b/src/py/kaleido/__init__.py @@ -24,6 +24,8 @@ T = TypeVar("T") AnyIterable = Union[AsyncIterable[T], Iterable[T]] + from .kaleido import FigureGenerator + __all__ = [ "Kaleido", "PageGenerator", @@ -139,7 +141,7 @@ async def write_fig( async def write_fig_from_object( - generator: AnyIterable, # this could be more specific with [] + generator: AnyIterable[FigureGenerator], *, kopts: dict[str, Any] | None = None, **kwargs, diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 1be6331e..43893f5e 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -39,6 +39,12 @@ # Iterable & Sized Listish: TypeAlias = Union[Tuple[T], List[T], ValuesView[T]] + class FigureGenerator(TypedDict): + fig: Required[_fig_tools.Figurish] + path: NotRequired[None | str | Path] + opts: NotRequired[_fig_tools.LayoutOpts | None] + topojson: NotRequired[None | str] + class Kaleido(choreo.Browser): tabs_ready: asyncio.Queue[_KaleidoTab] @@ -248,16 +254,10 @@ def _create_render_task( ### API ### - class FigureGenerator(TypedDict): - fig: Required[_fig_tools.Figurish] - path: NotRequired[None | str | Path] - opts: NotRequired[_fig_tools.LayoutOpts | None] - topojson: NotRequired[None | str] - # also write_fig_from_dict async def write_fig_from_object( self, - generator: FigureGenerator, # what should we accept here + generator: AnyIterable[FigureGenerator], *, cancel_on_error=False, ) -> None: From 51262b0994ffbec3ecccb1177de084d4bf3bcb96 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 19:19:22 -0500 Subject: [PATCH 074/167] Make readability changes to test_fig_tools.py --- src/py/tests/test_fig_tools.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/py/tests/test_fig_tools.py b/src/py/tests/test_fig_tools.py index 135a7c89..aa89326a 100644 --- a/src/py/tests/test_fig_tools.py +++ b/src/py/tests/test_fig_tools.py @@ -6,12 +6,13 @@ sources = ["argument", "layout", "template", "default"] values = [None, 150, 800, 1500] +values2 = [None, 300, 1000, 1300] @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]) +@pytest.mark.parametrize("height_value", values2) def test_get_figure_dimensions(width_source, height_source, width_value, height_value): """Test _get_figure_dimensions with all combinations of width/height sources.""" @@ -113,8 +114,8 @@ def test_next_filename_similar_names_ignored(tmp_path): 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 + prefix = "test-?f$ile_name" + ext = "s$v&*g" # set up to be parameterized but not # Create some files (tmp_path / f"{prefix}.{ext}").touch() @@ -171,7 +172,7 @@ def test_build_full_path_no_path_input(fig_fixture): def test_build_full_path_no_suffix_directory(tmp_path, fig_fixture): - """Test _build_full_path with path having no suffix.""" + """Test _build_full_path with path to directory having no suffix.""" fig_dict, expected_prefix = fig_fixture # Test directory no suffix From 3f9709821a070ee1ce862745c0881bf5876c6bcf Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 19:22:45 -0500 Subject: [PATCH 075/167] Add extraneous lines to force checks on string patches. --- src/py/tests/test_init.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/py/tests/test_init.py b/src/py/tests/test_init.py index 79fb5ba3..5ff5498f 100644 --- a/src/py/tests/test_init.py +++ b/src/py/tests/test_init.py @@ -24,6 +24,10 @@ def kwargs(): return {"width": 800} +# line serves to force static check of string in @patch +_ = kaleido._sync_server.GlobalKaleidoServer.open # noqa: SLF001 + + @patch("kaleido._sync_server.GlobalKaleidoServer.open") def test_start_sync_server_passes_args(mock_open, args, kwargs): """Test that start_sync_server passes args and silence_warnings correctly.""" @@ -37,6 +41,10 @@ def test_start_sync_server_passes_args(mock_open, args, kwargs): mock_open.assert_called_with(*args, silence_warnings=True, **kwargs) +# line serves to force static check of string in @patch +_ = kaleido._sync_server.GlobalKaleidoServer.close # noqa: SLF001 + + @patch("kaleido._sync_server.GlobalKaleidoServer.close") def test_stop_sync_server_passes_args(mock_close): """Test that stop_sync_server passes silence_warnings correctly.""" @@ -50,6 +58,10 @@ def test_stop_sync_server_passes_args(mock_close): mock_close.assert_called_with(silence_warnings=True) +# line serves to force static check of string in @patch +_ = kaleido.Kaleido + + @patch("kaleido.Kaleido") async def test_async_wrapper_functions(mock_kaleido_class): """Test all async wrapper functions pass arguments correctly. @@ -124,6 +136,11 @@ async def test_async_wrapper_functions(mock_kaleido_class): mock_kaleido.write_fig_from_object.assert_called_with(generator) +# line serves to force static check of string in @patch +_ = kaleido._sync_server.GlobalKaleidoServer.is_running # noqa: SLF001 +_ = kaleido._sync_server.GlobalKaleidoServer.call_function # noqa: SLF001 + + @patch("kaleido._sync_server.GlobalKaleidoServer.is_running") @patch("kaleido._sync_server.GlobalKaleidoServer.call_function") def test_sync_wrapper_server(mock_call_function, mock_is_running, args, kwargs): @@ -147,6 +164,11 @@ def test_sync_wrapper_server(mock_call_function, mock_is_running, args, kwargs): mock_call_function.assert_called_with("write_fig_from_object", *args, **kwargs) +# line serves to force static check of string in @patch +_ = kaleido._sync_server.GlobalKaleidoServer.is_running # noqa: SLF001 +_ = kaleido._sync_server.oneshot_async_run # noqa: SLF001 + + @patch("kaleido._sync_server.GlobalKaleidoServer.is_running") @patch("kaleido._sync_server.oneshot_async_run") def test_sync_wrapper_oneshot(mock_oneshot_run, mock_is_running, args, kwargs): From 42b8121adf6f37eaa3a845c64ea5da8f6b6c67bc Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 19:29:18 -0500 Subject: [PATCH 076/167] Add single comment. --- src/py/tests/test_kaleido.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/tests/test_kaleido.py b/src/py/tests/test_kaleido.py index 6c83dbab..ca41131c 100644 --- a/src/py/tests/test_kaleido.py +++ b/src/py/tests/test_kaleido.py @@ -186,6 +186,7 @@ async def test_write_fig_argument_passthrough( # noqa: PLR0913 ): """Test that write_fig properly passes arguments to write_fig_from_object.""" pytest.skip("Remove this failure line and the comment above after the refactor!") + # Also add one for calc, its just a pass through tester. test_path = tmp_path / f"{path}.{format_type}" opts = {"format": format_type, "width": width, "height": height} From caa302e1d7073d229f49b3bb5f75f9296365ace3 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:07:54 -0500 Subject: [PATCH 077/167] Factor file searching out of page generator. --- src/py/kaleido/_page_generator.py | 25 ++++++++++--------------- src/py/kaleido/_utils.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 981ae2e7..c81a3448 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -1,11 +1,11 @@ from __future__ import annotations from pathlib import Path -from urllib.parse import urlparse -from urllib.request import url2pathname import logistro +from . import _utils + _logger = logistro.getLogger(__name__) DEFAULT_PLOTLY = "https://cdn.plot.ly/plotly-2.35.2.js" @@ -17,19 +17,14 @@ KJS_PATH = Path(__file__).resolve().parent / "vendor" / "kaleido_scopes.js" -def _ensure_path(path: Path | str | tuple[str | Path, str]) -> None: +def _ensure_file(path: Path | str | tuple[str | Path, str]) -> None: if isinstance(path, tuple): path = path[0] - _logger.debug(f"Ensuring path {path!s}") - parsed = urlparse(str(path)) - _logger.debug(f"Parsed file path: {parsed}") - if parsed.scheme.startswith("http"): # is url + if isinstance(path, Path) and path.is_file(): # noqa: SIM114 clarity + return + elif _utils.is_url(path): # noqa: SIM114 clarity return - elif parsed.scheme.startswith("file"): - if (_p := Path(url2pathname(parsed.path))).exists(): - return - _logger.error(f"File parsed to: {_p}") - elif Path(path).exists(): + elif _utils.get_path(path).is_file(): return raise FileNotFoundError(f"{path!s} does not exist.") @@ -91,7 +86,7 @@ def __init__( # noqa: C901 if mathjax is None or mathjax is True: mathjax = DEFAULT_MATHJAX elif mathjax: - _ensure_path(mathjax) + _ensure_file(mathjax) self._scripts.append(mathjax) if force_cdn: plotly = (DEFAULT_PLOTLY, "utf-8") @@ -119,13 +114,13 @@ def __init__( # noqa: C901 _logger.info("Plotly not installed. Using CDN.") plotly = (DEFAULT_PLOTLY, "utf-8") elif isinstance(plotly, (str, Path)): - _ensure_path(plotly) + _ensure_file(plotly) plotly = (plotly, "utf-8") _logger.debug(f"Plotly script: {plotly}") self._scripts.append(plotly) if others: for o in others: - _ensure_path(o) + _ensure_file(o) self._scripts.extend(others) def generate_index(self): diff --git a/src/py/kaleido/_utils.py b/src/py/kaleido/_utils.py index 15db4930..23d5825e 100644 --- a/src/py/kaleido/_utils.py +++ b/src/py/kaleido/_utils.py @@ -4,7 +4,10 @@ import warnings from functools import partial from importlib.metadata import PackageNotFoundError, version +from pathlib import Path from typing import TYPE_CHECKING +from urllib.parse import urlparse +from urllib.request import url2pathname import logistro from packaging.version import Version @@ -99,3 +102,15 @@ def warn_incompatible_plotly(): # Since this compatibility check is just a convenience, # we don't want to block the whole library if there's an issue _logger.info("Error while checking Plotly version.", exc_info=e) + + +def get_path(p: str) -> Path | None: + parsed = urlparse(str(p)) + + return Path( + url2pathname(parsed.path) if parsed.scheme.startswith("file") else p, + ) + + +def is_url(p: str) -> bool: + return urlparse(str(p)).scheme.startswith("http") From 839ceddbd2496b84d21b6e60cac9fe5c62946866 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:08:15 -0500 Subject: [PATCH 078/167] Add test to make sure page generator is passed a file --- src/py/tests/test_page_generator.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/py/tests/test_page_generator.py b/src/py/tests/test_page_generator.py index e213d6a9..2324d366 100644 --- a/src/py/tests/test_page_generator.py +++ b/src/py/tests/test_page_generator.py @@ -361,6 +361,10 @@ async def test_combined_overrides(tmp_path, data): assert len(scripts) == expected_count +# note: the logic below was extracted to utilities, +# so in a way its tested twice since tests were developed for that file + + # Test file path validation async def test_existing_file_path(temp_js_file): """Test that existing file paths work with and without file:/// protocol.""" @@ -383,9 +387,14 @@ async def test_existing_file_path(temp_js_file): assert scripts_uri[2].endswith("kaleido_scopes.js") +# Claude, please for all bottom, please make a fixture like "existing_dir" +# and make sure we still raise an error + + async def test_nonexistent_file_path_raises_error( nonexistent_file_path, nonexistent_file_uri, + tmp_path, ): """Test that nonexistent file paths raise FileNotFoundError.""" # Test with regular path @@ -399,10 +408,15 @@ async def test_nonexistent_file_path_raises_error( with pytest.raises(FileNotFoundError): PageGenerator(plotly=nonexistent_file_uri) + # Test that existing directory raises error + with pytest.raises(FileNotFoundError): + PageGenerator(plotly=str(tmp_path)) + async def test_mathjax_nonexistent_file_raises_error( nonexistent_file_path, nonexistent_file_uri, + tmp_path, ): """Test that nonexistent mathjax file raises FileNotFoundError.""" # Test with regular path @@ -416,10 +430,15 @@ async def test_mathjax_nonexistent_file_raises_error( with pytest.raises(FileNotFoundError): PageGenerator(mathjax=nonexistent_file_uri) + # Test that existing directory raises error + with pytest.raises(FileNotFoundError): + PageGenerator(mathjax=str(tmp_path)) + async def test_others_nonexistent_file_raises_error( nonexistent_file_path, nonexistent_file_uri, + tmp_path, ): """Test that nonexistent file in others list raises FileNotFoundError.""" # Test with regular path @@ -433,6 +452,10 @@ async def test_others_nonexistent_file_raises_error( with pytest.raises(FileNotFoundError): PageGenerator(others=[nonexistent_file_uri]) + # Test that existing directory raises error + with pytest.raises(FileNotFoundError): + PageGenerator(others=[str(tmp_path)]) + # Test HTTP URLs (should not raise FileNotFoundError) async def test_http_urls_skip_file_validation(): From 8c66445a18652ebec3fe05749c75f61b8450cae1 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:14:08 -0500 Subject: [PATCH 079/167] Change is_url to is_httpish --- src/py/kaleido/_page_generator.py | 2 +- src/py/kaleido/_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index c81a3448..4baba313 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -22,7 +22,7 @@ def _ensure_file(path: Path | str | tuple[str | Path, str]) -> None: path = path[0] if isinstance(path, Path) and path.is_file(): # noqa: SIM114 clarity return - elif _utils.is_url(path): # noqa: SIM114 clarity + elif _utils.is_httpish(path): # noqa: SIM114 clarity return elif _utils.get_path(path).is_file(): return diff --git a/src/py/kaleido/_utils.py b/src/py/kaleido/_utils.py index 23d5825e..10a69c52 100644 --- a/src/py/kaleido/_utils.py +++ b/src/py/kaleido/_utils.py @@ -112,5 +112,5 @@ def get_path(p: str) -> Path | None: ) -def is_url(p: str) -> bool: +def is_httpish(p: str) -> bool: return urlparse(str(p)).scheme.startswith("http") From 302c30699125621cade85740bd7bfb72e42d95aa Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:14:24 -0500 Subject: [PATCH 080/167] Add tests for is_httpish/get_path --- src/py/tests/test_utils.py | 51 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/py/tests/test_utils.py diff --git a/src/py/tests/test_utils.py b/src/py/tests/test_utils.py new file mode 100644 index 00000000..7c92d892 --- /dev/null +++ b/src/py/tests/test_utils.py @@ -0,0 +1,51 @@ +from pathlib import Path + +import pytest + +from kaleido._utils import get_path, is_httpish + +pytestmark = pytest.mark.asyncio(loop_scope="function") + +# ruff: noqa: S108 + + +# Test get_path utility function +async def test_get_path_with_file_uri(): + """Test get_path function with file:// URIs.""" + file_uri = "file:///tmp/test.js" + result = get_path(file_uri) + assert result == Path("/tmp/test.js") + + +async def test_get_path_with_regular_path(): + """Test get_path function with regular file paths.""" + regular_path = "/tmp/test.js" + result = get_path(regular_path) + assert result == Path("/tmp/test.js") + + +async def test_get_path_with_http_url(): + """Test get_path function with HTTP URLs.""" + http_url = "https://example.com/test.js" + result = get_path(http_url) + assert result == Path("https://example.com/test.js") + + +# Test is_httpish utility function +async def test_is_httpish_with_http(): + """Test is_httpish function with HTTP URLs.""" + assert is_httpish("http://example.com/test.js") is True + assert is_httpish("https://example.com/test.js") is True + + +async def test_is_httpish_with_file_paths(): + """Test is_httpish function with file paths.""" + assert is_httpish("/tmp/test.js") is False + assert is_httpish("test.js") is False + assert is_httpish("file:///tmp/test.js") is False + + +async def test_is_httpish_with_other_schemes(): + """Test is_httpish function with other URL schemes.""" + assert is_httpish("ftp://example.com/test.js") is False + assert is_httpish("mailto:test@example.com") is False From f6da4da20b775b62c2394f31aa9f572ba90f75c8 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:32:21 -0500 Subject: [PATCH 081/167] Use _utils to simplify kaleido. --- src/py/kaleido/_utils.py | 4 +++- src/py/kaleido/kaleido.py | 24 +++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/py/kaleido/_utils.py b/src/py/kaleido/_utils.py index 10a69c52..820b61d3 100644 --- a/src/py/kaleido/_utils.py +++ b/src/py/kaleido/_utils.py @@ -104,7 +104,9 @@ def warn_incompatible_plotly(): _logger.info("Error while checking Plotly version.", exc_info=e) -def get_path(p: str) -> Path | None: +def get_path(p: str | Path) -> Path: + if isinstance(p, Path): + return p parsed = urlparse(str(p)) return Path( diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 43893f5e..0f0791a6 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -6,7 +6,6 @@ from collections.abc import AsyncIterable, Iterable from pathlib import Path from typing import TYPE_CHECKING, TypedDict -from urllib.parse import unquote, urlparse import choreographer as choreo import logistro @@ -102,25 +101,24 @@ async def open(self): page = self._saved_page_arg del self._saved_page_arg - if isinstance(page, str): - if page.startswith(r"file://") and Path(unquote(urlparse(page).path)): - self._index = page - elif Path(page).is_file(): - self._index = Path(page).as_uri() - else: - raise FileNotFoundError(f"{page} does not exist.") - elif isinstance(page, Path): - if page.is_file(): - self._index = page.as_uri() + if isinstance(page, (Path, str)): + if (_p := _utils.get_path(page)).is_file(): + self._index = _p.as_uri() else: raise FileNotFoundError(f"{page!s} does not exist.") - else: + elif not page or hasattr(page, "generate_index"): self._tmp_dir = TmpDirectory(sneak=self.is_isolated()) index = self._tmp_dir.path / "index.html" self._index = index.as_uri() if not page: page = PageGenerator(plotly=self._plotlyjs, mathjax=self._mathjax) - page.generate_index(index) + with index.open("w") as f: # is blocking but ok + f.write(page.generate_index()) + else: + raise TypeError( + "A page generator must be one of: None, a" + " PageGenerator, or a file path to an index.html", + ) await super().open() async def _conform_tabs(self, tabs: Listish[choreo.Tab] | None = None) -> None: From 6c4f8a696602d2e618ff54fbf6cf90f805d6ede7 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:37:34 -0500 Subject: [PATCH 082/167] Expand utils tests. --- src/py/tests/test_utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/py/tests/test_utils.py b/src/py/tests/test_utils.py index 7c92d892..888447bc 100644 --- a/src/py/tests/test_utils.py +++ b/src/py/tests/test_utils.py @@ -13,22 +13,28 @@ async def test_get_path_with_file_uri(): """Test get_path function with file:// URIs.""" file_uri = "file:///tmp/test.js" + path_wrapped = Path(file_uri) result = get_path(file_uri) assert result == Path("/tmp/test.js") + assert get_path(path_wrapped) is path_wrapped async def test_get_path_with_regular_path(): """Test get_path function with regular file paths.""" regular_path = "/tmp/test.js" + path_wrapped = Path(regular_path) result = get_path(regular_path) assert result == Path("/tmp/test.js") + assert get_path(path_wrapped) is path_wrapped async def test_get_path_with_http_url(): """Test get_path function with HTTP URLs.""" http_url = "https://example.com/test.js" + path_wrapped = Path(http_url) result = get_path(http_url) assert result == Path("https://example.com/test.js") + assert get_path(path_wrapped) is path_wrapped # Test is_httpish utility function From 9c8cc6bdf783ae5e707ec12fbb072a4b0ff72058 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Mon, 1 Sep 2025 20:40:08 -0500 Subject: [PATCH 083/167] Fix logic in file checker. --- src/py/kaleido/_page_generator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 4baba313..2fa2bf8e 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -20,8 +20,11 @@ def _ensure_file(path: Path | str | tuple[str | Path, str]) -> None: if isinstance(path, tuple): path = path[0] - if isinstance(path, Path) and path.is_file(): # noqa: SIM114 clarity - return + if isinstance(path, Path): + if path.is_file(): + return + else: + pass # FileNotFound elif _utils.is_httpish(path): # noqa: SIM114 clarity return elif _utils.get_path(path).is_file(): From 409645a731f12900b8aa0db0abf6f6924c8005e9 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 2 Sep 2025 11:53:24 -0500 Subject: [PATCH 084/167] Improve a bit the documentation. --- src/py/kaleido/__init__.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/py/kaleido/__init__.py b/src/py/kaleido/__init__.py index 0936f0db..dce75c47 100644 --- a/src/py/kaleido/__init__.py +++ b/src/py/kaleido/__init__.py @@ -52,7 +52,7 @@ def start_sync_server(*args: Any, silence_warnings: bool = False, **kwargs: Any) function will warn you if the server is already running. This wrapper function takes the exact same arguments as kaleido.Kaleido(), - except one extra, `silence_warnings`. + except one extra: `silence_warnings`. Args: *args: all arguments `Kaleido()` would take. @@ -66,11 +66,13 @@ def start_sync_server(*args: Any, silence_warnings: bool = False, **kwargs: Any) def stop_sync_server(*, silence_warnings: bool = False): """ - Stop the kaleido server. It can be restarted. Warns if not started. + Stop the kaleido server. It can be restarted. + + This function will warn you if the server is already stopped. Args: silence_warnings: (bool, default False): If True, don't emit warning if - stopping a server that's not running. + stopping an already stopped server. """ _global_server.close(silence_warnings=silence_warnings) @@ -88,14 +90,13 @@ async def calc_fig( Return binary for plotly figure. A convenience wrapper for `Kaleido.calc_fig()` which starts a `Kaleido` and - executes the `calc_fig()`. + executes `calc_fig()`. It takes an additional argument, `kopts`, a dictionary of arguments to pass to the kaleido process. See the `kaleido.Kaleido` docs. However, `calc_fig()` will never use more than one processor, so any `n` value will be overridden. - - See documentation for `Kaleido.calc_fig()`. + See also the documentation for `Kaleido.calc_fig()`. """ kopts = kopts or {} @@ -124,10 +125,9 @@ async def write_fig( A convenience wrapper for `Kaleido.write_fig()` which starts a `Kaleido` and executes the `write_fig()`. It takes an additional argument, `kopts`, a dictionary of arguments to pass - to the kaleido process. See the `kaleido.Kaleido` docs. + to the `Kaleido` constructor. See the `kaleido.Kaleido` docs. - - See documentation for `Kaleido.write_fig()` for the other arguments. + See also the documentation for `Kaleido.write_fig()`. """ async with Kaleido(**(kopts or {})) as k: @@ -147,15 +147,14 @@ async def write_fig_from_object( **kwargs, ): """ - Write a plotly figure(s) to a file. + Write a plotly figure(s) to a file specified by a dictionary generator. A convenience wrapper for `Kaleido.write_fig_from_object()` which starts a `Kaleido` and executes the `write_fig_from_object()` It takes an additional argument, `kopts`, a dictionary of arguments to pass - to the kaleido process. See the `kaleido.Kaleido` docs. + to the `Kaleido` constructor. See the `kaleido.Kaleido` docs. - See documentation for `Kaleido.write_fig_from_object()` for the other - arguments. + See also the documentation for `Kaleido.write_fig_from_object()`. """ async with Kaleido(**(kopts or {})) as k: From f6498c179e930297bdbdd0422abe4fed744edc92 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 2 Sep 2025 12:37:05 -0500 Subject: [PATCH 085/167] Improve docs and add notes: Also turn off mandatory issue linking for TODO --- src/py/kaleido/__init__.py | 4 +- src/py/kaleido/kaleido.py | 115 ++++++++++++++++++++++++++++++------- src/py/pyproject.toml | 1 + 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/src/py/kaleido/__init__.py b/src/py/kaleido/__init__.py index dce75c47..ebdbe698 100644 --- a/src/py/kaleido/__init__.py +++ b/src/py/kaleido/__init__.py @@ -117,7 +117,7 @@ async def write_fig( *, topojson: str | None = None, kopts: dict[str, Any] | None = None, - **kwargs, + **kwargs, # TODO(AJP): what might we pass here? ): """ Write a plotly figure(s) to a file. @@ -144,7 +144,7 @@ async def write_fig_from_object( generator: AnyIterable[FigureGenerator], *, kopts: dict[str, Any] | None = None, - **kwargs, + **kwargs, # TODO(AJP): what might we pass here? ): """ Write a plotly figure(s) to a file specified by a dictionary generator. diff --git a/src/py/kaleido/kaleido.py b/src/py/kaleido/kaleido.py index 0f0791a6..39bc3284 100644 --- a/src/py/kaleido/kaleido.py +++ b/src/py/kaleido/kaleido.py @@ -39,6 +39,8 @@ Listish: TypeAlias = Union[Tuple[T], List[T], ValuesView[T]] class FigureGenerator(TypedDict): + """The type a generator returns for `write_fig_from_object`.""" + fig: Required[_fig_tools.Figurish] path: NotRequired[None | str | Path] opts: NotRequired[_fig_tools.LayoutOpts | None] @@ -46,7 +48,31 @@ class FigureGenerator(TypedDict): class Kaleido(choreo.Browser): + """ + The Kaleido object provides a browser to render and write plotly figures. + + It provides methods to render said figures, and manages any number of tabs + in a work queue. Start it one of a few equal ways: + + async with Kaleido() as k: + ... + + # or + + k = await Kaleido() + ... + await k.close() + + # or + + k = Kaleido() + await k.open() + ... + await.k.close() + """ + tabs_ready: asyncio.Queue[_KaleidoTab] + """A queue of tabs ready to process a kaleido figure.""" _tab_requeue_tasks: set[asyncio.Task] _main_render_coroutines: set[asyncio.Task] # technically Tasks, user sees coroutines @@ -58,15 +84,59 @@ class Kaleido(choreo.Browser): def __init__( # noqa: PLR0913 self, - *args: Any, # does choreographer take args? + *args: Any, # TODO(AJP): does choreographer take args? n: int = 1, timeout: int | None = 90, page_generator: None | PageGenerator | str | Path = None, - plotlyjs: str | Path | None = None, - mathjax: str | Path | None = None, + plotlyjs: str | Path | None = None, # TODO(AJP): with page generator + mathjax: str | Path | None = None, # TODO(AJP): with page generator? stepper: bool = False, **kwargs: Any, ) -> None: + """ + Create a new Kaleido process for rendering plotly figures. + + Args: + *args (Any): + Passed through to underlying choreographer.Browser() + n (int, optional): + Number of processors to use (parallelization). Defaults to 1. + + timeout (int | None, optional): + Number of seconds to wait to render any one image. None for no + timeout. Defaults to 90. + + page_generator (None | PageGenerator | str | Path, optional): + A PageGenerator object can be used for deep customization of the + plotly template page. This is for development use. You can also + pass a string or path directly to an index.html, or any object + with a `generate_index()->str function that prints an HTML + ppage. Defaults to None. + + plotlyjs (str | Path | None, optional): + A path or URL to a plotly.js file. Defaults to None- which means + to use the plotly.js included with your version of plotly.py or + if not installed, the latest version available via CDN. + + mathjax (str | Path | None, optional): + A path or URL to a mathjax.js file. If false, mathjax is + disabled. Defaults to None- which means to use version 2.35 via + CDN. + + stepper (bool, optional): + A diagnostic tool that will ask the user to press enter between + rendering each image. Only useful if also used with + `headless=False`. See below. Defaults to False. + + **kwargs (Any): + Additional keyword arguments passed through to the underlying + Choreographer.browser constructor. Notable options include, + `headless=False` (show window), `enable_sandbox=True` (turn on + sandboxing), and `enable_gpu=True` which will allow use of the + GPU. The defaults for these options are True, False, False + respectively. + + """ self._tab_requeue_tasks = set() self._main_render_coroutines = set() self.tabs_ready = asyncio.Queue(maxsize=0) @@ -90,14 +160,15 @@ def __init__( # noqa: PLR0913 raise ChromeNotFoundError( "Kaleido v1 and later requires Chrome to be installed. " "To install Chrome, use the CLI command `kaleido_get_chrome`, " - "or from Python, use either `kaleido.get_chrome()` " + "or from Python, use either `await kaleido.get_chrome()` " "or `kaleido.get_chrome_sync()`.", - ) from ChromeNotFoundError - # do this during open because it requires close + ) from None # overwriting the error entirely. + + # save this for open() because it requires close() self._saved_page_arg = page async def open(self): - """Build temporary file if we need one.""" + """Build page and temporary file if we need one, then opens browser.""" page = self._saved_page_arg del self._saved_page_arg @@ -116,11 +187,18 @@ async def open(self): f.write(page.generate_index()) else: raise TypeError( - "A page generator must be one of: None, a" - " PageGenerator, or a file path to an index.html", + "page_generator must be one of: None, a" + " PageGenerator, or a file path to an index.html.", ) await super().open() + async def _create_kaleido_tab(self) -> None: + tab = await super().create_tab( + url="", + window=True, + ) + await self._conform_tabs([tab]) + async def _conform_tabs(self, tabs: Listish[choreo.Tab] | None = None) -> None: if not tabs: tabs = self.tabs.values() @@ -130,6 +208,8 @@ async def _conform_tabs(self, tabs: Listish[choreo.Tab] | None = None) -> None: tab.subscribe("*", _utils.event_printer(f"tab-{i!s}: Event Dump:")) kaleido_tabs = [_KaleidoTab(tab, _stepper=self._stepper) for tab in tabs] + # TODO(AJP): why doesn't stepper use the global? + # KaleidoTab has access to Kaleido? await asyncio.gather(*(tab.navigate(self._index) for tab in kaleido_tabs)) @@ -142,7 +222,7 @@ async def populate_targets(self) -> None: Override the browser populate_targets to ensure the correct page. Is called automatically during initialization, and should only be called - once ever per object. + once. """ await super().populate_targets() await self._conform_tabs() @@ -189,13 +269,6 @@ async def __aexit__( ### TAB MANAGEMENT FUNCTIONS #### - async def _create_kaleido_tab(self) -> None: - tab = await super().create_tab( - url="", - window=True, - ) - await self._conform_tabs([tab]) - async def _get_kaleido_tab(self) -> _KaleidoTab: _logger.info(f"Getting tab from queue (has {self.tabs_ready.qsize()})") if not self._total_tabs: @@ -236,10 +309,10 @@ def _create_render_task( self._timeout, ), ) - # create_task_log_error is a create_task helper, set and forget - # to create a task and add a post-run action which checks for - # errors and logs them. this pattern is the best i've found - # but somehow its still pretty distressing + + # a weak solution to chaining tasks + # maybe consider an async closure instead + # avoids try/except.... t.add_done_callback( lambda _f: self._tab_requeue_tasks.add( _utils.create_task_log_error( diff --git a/src/py/pyproject.toml b/src/py/pyproject.toml index 080be93f..be2965a0 100644 --- a/src/py/pyproject.toml +++ b/src/py/pyproject.toml @@ -74,6 +74,7 @@ ignore = [ "SIM105", # Too opionated (try-except-pass) "PT003", # scope="function" implied but I like readability "G004", # fstrings in my logs + "TD003", # issue link in todos ] [tool.ruff.lint.per-file-ignores] From e490af7cc5f1aed15a061684e0b927d19f1d2027 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 2 Sep 2025 12:43:25 -0500 Subject: [PATCH 086/167] Type and improve comments. --- src/py/kaleido/_sync_server.py | 36 +++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/py/kaleido/_sync_server.py b/src/py/kaleido/_sync_server.py index 714fcce2..740bd902 100644 --- a/src/py/kaleido/_sync_server.py +++ b/src/py/kaleido/_sync_server.py @@ -11,7 +11,7 @@ from .kaleido import Kaleido if TYPE_CHECKING: - from typing import Any + from typing import Any, Callable class Task(NamedTuple): @@ -95,7 +95,20 @@ def close(self, *, silence_warnings=False): del self._return_queue self._initialized = False - def call_function(self, cmd: str, *args, **kwargs): + def call_function(self, cmd: str, *args: Any, **kwargs: Any): + """ + Call any function on the singleton Kaleido object. + + Preferred functions would be: `calc_fig`, `write_fig`, and + `write_fig_from_object`. Methods that doesn't exist will raise a + BaseException. + + Args: + cmd (str): the name of the method to call + args (Any): the method's arguments + kwargs (Any): the method's keyword arguments + + """ if not self.is_running(): raise RuntimeError("Can't call function on stopped server.") if kwargs.pop("kopts", None): @@ -113,7 +126,24 @@ def call_function(self, cmd: str, *args, **kwargs): return res -def oneshot_async_run(func, args: tuple[Any, ...], kwargs: dict): +def oneshot_async_run( + func: Callable, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> Any: + """ + Run a thread to execute a single function. + + Used by _sync functions in + `__init__` to ensure their async loop is separate from the users main + one. + + Args: + func: the function to run + args: a tuple of arguments to pass + kwargs: a dictionary of keyword arguments to pass + + """ q: Queue[Any] = Queue(maxsize=1) def run(func, q, *args, **kwargs): From bd4975935178a73ef5497d2f68176273331dcf7c Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 2 Sep 2025 13:15:26 -0500 Subject: [PATCH 087/167] Add clarifying comments to _fig_tools.py --- src/py/kaleido/_fig_tools.py | 62 ++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/src/py/kaleido/_fig_tools.py b/src/py/kaleido/_fig_tools.py index 30701808..b7aa2f8d 100644 --- a/src/py/kaleido/_fig_tools.py +++ b/src/py/kaleido/_fig_tools.py @@ -1,9 +1,18 @@ """ -Adapted from old code, it 1. validates, 2. write defaults, 3. packages object. +Tools to help prepare data for plotly.js from kaleido. -Its a bit complicated and mixed in order. +It 1. validates, 2. write defaults, 3. packages object. """ +# Adapted from old code, it's mixed in order, it should go in the order above. +# build_fig_spec should probably be factored out. +# 1. Validate +# 2. Write Defaults/Automatics +# 3. Packages Object +# The structure should be more readable. + +# The main entry point is the last function, build_fig_spec + from __future__ import annotations import glob @@ -21,6 +30,25 @@ Figurish = Any # Be nice to make it more specific, dictionary or something FormatString = Literal["png", "jpg", "jpeg", "webp", "svg", "json", "pdf"] + +# Input of to_spec (user gives us this) +class LayoutOpts(TypedDict, total=False): + format: FormatString | None + scale: int | float + height: int | float + width: int | float + + +# 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 + height: int | float + scale: int | float + data: Figurish + + _logger = logistro.getLogger(__name__) # constants @@ -39,6 +67,7 @@ ) +# validation function def _assert_format(ext: str) -> TypeGuard[FormatString]: if ext not in SUPPORTED_FORMATS: raise ValueError( @@ -47,6 +76,7 @@ def _assert_format(ext: str) -> TypeGuard[FormatString]: return True +# validation function def _is_figurish(o: Any) -> TypeGuard[Figurish]: valid = hasattr(o, "to_dict") or (isinstance(o, dict) and "data" in o) if not valid: @@ -58,6 +88,7 @@ def _is_figurish(o: Any) -> TypeGuard[Figurish]: return valid +# defaults function def _get_figure_dimensions( layout: dict, width: float | None, @@ -79,6 +110,7 @@ def _get_figure_dimensions( return width, height +# coercion function def _get_format(extension: str) -> FormatString: formatted_extension = extension.lower() if formatted_extension == "jpg": @@ -88,25 +120,7 @@ def _get_format(extension: str) -> FormatString: return formatted_extension -# Input of to_spec (user gives us this) -class LayoutOpts(TypedDict, total=False): - format: FormatString | None - scale: int | float - height: int | float - width: int | float - - -# 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 - height: int | float - scale: int | float - data: Figurish - - -# validate configuration options for kaleido.js and package like its wants +# does additional validation + defaults + packaging def to_spec(figure: Figurish, layout_opts: LayoutOpts) -> Spec: # Get figure layout layout = figure.get("layout", {}) @@ -142,7 +156,7 @@ def to_spec(figure: Figurish, layout_opts: LayoutOpts) -> Spec: } -# if we need to suffix the filename automatically: +# provides defaults 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 @@ -160,7 +174,7 @@ def _next_filename(path: Path | str, prefix: str, ext: str) -> str: return f"{prefix}.{ext}" if n == 1 else f"{prefix}-{n}.{ext}" -# validate and build full route if needed: +# provides defaults (and validation) def _build_full_path( path: Path | None, fig: Figurish, @@ -195,7 +209,7 @@ def _build_full_path( return full_path -# call all validators/automatic config fill-in/packaging in expected format +# does validation, defaults, and packaging. is main entry point def build_fig_spec( fig: Figurish, path: Path | str | None, From 0457d455c9c5e4177dd6c02dd9f6693677272b82 Mon Sep 17 00:00:00 2001 From: Andrew Pikul Date: Tue, 2 Sep 2025 13:30:05 -0500 Subject: [PATCH 088/167] More clearly define the URL tuple type. --- src/py/kaleido/_page_generator.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/py/kaleido/_page_generator.py b/src/py/kaleido/_page_generator.py index 2fa2bf8e..5ef13b5d 100644 --- a/src/py/kaleido/_page_generator.py +++ b/src/py/kaleido/_page_generator.py @@ -1,11 +1,16 @@ from __future__ import annotations from pathlib import Path +from typing import TYPE_CHECKING import logistro from . import _utils +if TYPE_CHECKING: + UrlAndCharset = tuple[str | Path, str] # type: TypeAlias + """A tuple to explicitly set charset= in the