diff --git a/src/gui/_drawable_canvas_compat.py b/src/gui/_drawable_canvas_compat.py new file mode 100644 index 0000000..e6b2258 --- /dev/null +++ b/src/gui/_drawable_canvas_compat.py @@ -0,0 +1,86 @@ +"""Compatibility shim for streamlit-drawable-canvas on modern Streamlit. + +``streamlit-drawable-canvas`` 0.9.3 (last release 2023) calls +``streamlit.elements.image.image_to_url(image, width, clamp, +channels, output_format, image_id)``. Streamlit ~1.30+ moved this +helper out of ``streamlit.elements.image`` and changed its +signature so the second positional argument is now a +``LayoutConfig`` dataclass instead of a plain ``int`` width. + +The canvas package hasn't been updated, so on modern Streamlit +its very first call fails with:: + + AttributeError: module 'streamlit.elements.image' + has no attribute 'image_to_url' + +This module re-attaches a wrapper at the old import path that +adapts the old call shape to the new function. Import it once +before any ``st_canvas`` call; idempotent. + +The shim is opt-in (not auto-installed at module import) so the +audit log of "I patched a third-party internal" is visible in +``grep`` rather than silently happening on every page load. +""" + +from __future__ import annotations + + +_PATCHED = False + + +def install() -> None: + """Install the ``image_to_url`` compatibility shim. + + Idempotent — safe to call multiple times. Returns silently + if the canvas package or Streamlit can't be imported (lets + the caller handle the "PDF deps missing" path on its own). + """ + global _PATCHED + if _PATCHED: + return + + try: + import streamlit.elements.image as _old_image_module + except ImportError: + return + + # Already present (old Streamlit, or already shimmed) — bail. + if hasattr(_old_image_module, "image_to_url"): + _PATCHED = True + return + + try: + from streamlit.elements.lib.image_utils import ( + image_to_url as _new_image_to_url, + ) + from streamlit.elements.lib.layout_utils import LayoutConfig + except ImportError: + # ``image_to_url`` is in some other location we don't know + # about yet — let the canvas surface its own error so we + # learn where to look. Don't fail silently. + return + + def _shim( + image, + width, + clamp, + channels, + output_format, + image_id, + ) -> str: + """Old API → new API. The old ``width=-1`` sentinel meant + "use the image's natural width", which is also the new + function's default behavior when ``LayoutConfig`` is left + unconfigured.""" + layout = LayoutConfig() + return _new_image_to_url( + image, + layout, + clamp, + channels, + output_format, + image_id, + ) + + _old_image_module.image_to_url = _shim + _PATCHED = True diff --git a/src/gui/pages/10_PDF_Extractor.py b/src/gui/pages/10_PDF_Extractor.py index fc6e944..035a25f 100644 --- a/src/gui/pages/10_PDF_Extractor.py +++ b/src/gui/pages/10_PDF_Extractor.py @@ -30,6 +30,7 @@ if str(_project_root) not in sys.path: sys.path.insert(0, str(_project_root)) from src.audit import log_event, log_page_open +from src.gui._drawable_canvas_compat import install as _install_canvas_compat from src.gui.components import hide_streamlit_chrome, render_sticky_footer from src.pdf_extract import ( PdfDependencyMissing, @@ -39,6 +40,12 @@ from src.pdf_extract import ( render_page_image, ) +# streamlit-drawable-canvas 0.9.3 calls a Streamlit internal +# (``image_to_url``) that was relocated in Streamlit ~1.30+. The +# shim re-attaches the old import path with a signature adapter. +# See ``src/gui/_drawable_canvas_compat.py`` for the why. +_install_canvas_compat() + def _pdf_deps_status() -> tuple[bool, list[str]]: """Probe each runtime PDF dep without forcing the user to hit the diff --git a/tests/test_drawable_canvas_compat.py b/tests/test_drawable_canvas_compat.py new file mode 100644 index 0000000..22d90ba --- /dev/null +++ b/tests/test_drawable_canvas_compat.py @@ -0,0 +1,116 @@ +"""Tests for the streamlit-drawable-canvas compatibility shim. + +The shim re-attaches ``image_to_url`` to ``streamlit.elements.image`` +on modern Streamlit where the helper was relocated to +``streamlit.elements.lib.image_utils`` and given a new signature +(takes a ``LayoutConfig`` dataclass instead of a plain ``int`` +width). + +If this test ever fails on a Streamlit upgrade, it almost +certainly means the ``image_to_url`` function moved AGAIN — the +shim's fallback message points to where to look. Update +``_drawable_canvas_compat.py`` to find the new location. +""" + +from __future__ import annotations + +import sys +import types + + +def test_shim_attaches_image_to_url(): + """After ``install()`` the old import path resolves to a + callable, even on modern Streamlit where the original was + relocated.""" + # Force a fresh import so the module-level _PATCHED guard + # doesn't short-circuit between tests. + sys.modules.pop("src.gui._drawable_canvas_compat", None) + from src.gui._drawable_canvas_compat import install + install() + import streamlit.elements.image as old_loc + assert hasattr(old_loc, "image_to_url") + assert callable(old_loc.image_to_url) + + +def test_shim_is_idempotent(): + """Calling ``install()`` twice doesn't double-wrap or break + anything — important because the page module imports + calls + it once, and a Streamlit script-rerun re-executes the page + module top-to-bottom.""" + sys.modules.pop("src.gui._drawable_canvas_compat", None) + from src.gui._drawable_canvas_compat import install + install() + import streamlit.elements.image as old_loc + first = old_loc.image_to_url + install() + second = old_loc.image_to_url + assert first is second + + +def test_shim_no_op_when_image_to_url_already_present(): + """If a future Streamlit restores ``image_to_url`` at the old + location, the shim must not overwrite it — leave the upstream + function in place so the canvas package gets the official + version, not our compatibility wrapper.""" + sys.modules.pop("src.gui._drawable_canvas_compat", None) + import streamlit.elements.image as old_loc + + sentinel = lambda *a, **kw: "sentinel-url" # noqa: E731 + old_loc.image_to_url = sentinel + try: + from src.gui._drawable_canvas_compat import install + install() + assert old_loc.image_to_url is sentinel, ( + "Shim must not clobber an existing image_to_url." + ) + finally: + # Tidy up so subsequent tests see a clean module. + delattr(old_loc, "image_to_url") + sys.modules.pop("src.gui._drawable_canvas_compat", None) + + +def test_shim_calls_new_function_with_layout_config(): + """The shim's wrapper must translate the old ``(image, width, + clamp, channels, output_format, image_id)`` call into the new + ``(image, layout_config, …)`` signature without breaking.""" + sys.modules.pop("src.gui._drawable_canvas_compat", None) + import streamlit.elements.image as old_loc + if hasattr(old_loc, "image_to_url"): + delattr(old_loc, "image_to_url") + + # Replace the new function with a recorder so we can inspect + # what arguments the shim passed through. + from streamlit.elements.lib import image_utils + captured: dict = {} + original = image_utils.image_to_url + + def recorder(image, layout_config, clamp, channels, output_format, image_id): + captured["image"] = image + captured["layout_config"] = layout_config + captured["clamp"] = clamp + captured["channels"] = channels + captured["output_format"] = output_format + captured["image_id"] = image_id + return "fake-url" + + image_utils.image_to_url = recorder + try: + from src.gui._drawable_canvas_compat import install + install() + result = old_loc.image_to_url( + "fake-image", -1, False, "RGB", "PNG", "test-id", + ) + assert result == "fake-url" + assert captured["image"] == "fake-image" + assert captured["clamp"] is False + assert captured["channels"] == "RGB" + assert captured["output_format"] == "PNG" + assert captured["image_id"] == "test-id" + # The shim wraps the int width into a LayoutConfig. + from streamlit.elements.lib.layout_utils import LayoutConfig + assert isinstance(captured["layout_config"], LayoutConfig) + finally: + image_utils.image_to_url = original + if hasattr(old_loc, "image_to_url"): + delattr(old_loc, "image_to_url") + sys.modules.pop("src.gui._drawable_canvas_compat", None)