fix(pdf): shim image_to_url for drawable-canvas on modern Streamlit
User hit ``AttributeError: module 'streamlit.elements.image' has no attribute 'image_to_url'`` on first PDF import. Root cause: ``streamlit-drawable-canvas`` 0.9.3 (last upstream release 2023) calls a Streamlit internal that was relocated in Streamlit ~1.30+. The function moved from ``streamlit.elements.image`` to ``streamlit.elements.lib.image_utils`` AND its signature changed — the second positional argument is now a ``LayoutConfig`` dataclass instead of a plain ``int`` width. Three remedies considered: 1. Downgrade Streamlit. Reverses unrelated improvements + security fixes; not on the table. 2. Fork drawable-canvas. The maintenance hit isn't worth it for a one-line internal API change. 3. **Ship a compatibility shim.** Re-attach a wrapper at the old import path that adapts the old call shape to the new function. This is the standard workaround the wider Streamlit community has converged on for this exact regression. ``src/gui/_drawable_canvas_compat.py`` does (3). The ``install()`` helper is idempotent, opt-in (not auto-run at module import — a grep for ``_install_canvas_compat`` shows every call site), and no-ops if Streamlit hasn't moved the function OR if the new function isn't where we expect (lets the canvas surface a real error rather than papering over a different bug). The page calls ``_install_canvas_compat()`` once at module top before any ``st_canvas`` invocation; Streamlit's script-rerun model means this fires every page load but the ``_PATCHED`` guard makes re-runs free. The shim wraps the old ``width=int`` arg into a default-constructed ``LayoutConfig()`` — the old ``width=-1`` sentinel meant "use the image's natural width", which is also what an unconfigured LayoutConfig produces. Confirmed by inspecting Streamlit 1.57.0's ``image_utils.py``. 4 new tests pin the shim contract: - ``install()`` attaches ``image_to_url`` to the old path on modern Streamlit - Idempotent — calling twice doesn't double-wrap - Doesn't clobber a future Streamlit that restores the original at the old path - Translates ``(image, -1, False, "RGB", "PNG", "id")`` into a proper call to the new function with a ``LayoutConfig`` instance If a future Streamlit upgrade moves ``image_to_url`` AGAIN, the shim's silent-no-op fallback means the canvas error surfaces again and points at where to look. The shim doesn't paper over mysteries; it only patches the one specific relocation we know about. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
86
src/gui/_drawable_canvas_compat.py
Normal file
86
src/gui/_drawable_canvas_compat.py
Normal file
@@ -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
|
||||||
@@ -30,6 +30,7 @@ if str(_project_root) not in sys.path:
|
|||||||
sys.path.insert(0, str(_project_root))
|
sys.path.insert(0, str(_project_root))
|
||||||
|
|
||||||
from src.audit import log_event, log_page_open
|
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.gui.components import hide_streamlit_chrome, render_sticky_footer
|
||||||
from src.pdf_extract import (
|
from src.pdf_extract import (
|
||||||
PdfDependencyMissing,
|
PdfDependencyMissing,
|
||||||
@@ -39,6 +40,12 @@ from src.pdf_extract import (
|
|||||||
render_page_image,
|
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]]:
|
def _pdf_deps_status() -> tuple[bool, list[str]]:
|
||||||
"""Probe each runtime PDF dep without forcing the user to hit the
|
"""Probe each runtime PDF dep without forcing the user to hit the
|
||||||
|
|||||||
116
tests/test_drawable_canvas_compat.py
Normal file
116
tests/test_drawable_canvas_compat.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user