Files
datatools-dev/tests/test_drawable_canvas_compat.py
Michael 10015c40e1 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>
2026-05-19 23:29:20 +00:00

117 lines
4.5 KiB
Python

"""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)