Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions aztec_py/decode.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Optional decode utility backed by python-zxing."""
"""Optional decode utility — tries zxingcpp (no Java) then falls back to python-zxing."""

from __future__ import annotations

Expand All @@ -8,20 +8,55 @@
def decode(source: Any) -> Any:
"""Decode an Aztec symbol from an image path or PIL image.

Tries the ``zxingcpp`` backend first (pure C++, no Java runtime required).
Falls back to ``python-zxing`` (requires a JVM) if ``zxingcpp`` is absent.
Raises :class:`RuntimeError` with install instructions if neither is available.

Install the fast backend::

pip install "aztec-py[decode-fast]" # zxingcpp — no Java needed

Install the legacy backend::

pip install "aztec-py[decode]" # python-zxing — requires Java

Args:
source: File path, file object, or PIL image supported by ``python-zxing``.
source: File path (``str`` / ``pathlib.Path``) or PIL ``Image`` object.

Returns:
Decoded payload (`str` or `bytes` depending on the decoder/runtime).
Decoded payload string.

Raises:
RuntimeError: If optional decode dependencies are missing or decode fails.
RuntimeError: If no decode backend is installed or decode fails.
"""
# --- fast path: zxingcpp (C++, no JVM) ---
try:
import zxingcpp # type: ignore[import-not-found]

try:
import PIL.Image as _PILImage
except ImportError as exc:
raise RuntimeError(
"zxingcpp requires Pillow: pip install 'aztec-py[decode-fast]'"
) from exc

img = source if isinstance(source, _PILImage.Image) else _PILImage.open(source)
results = zxingcpp.read_barcodes(img)
if not results:
raise RuntimeError("Decoder returned no payload.")
# zxingcpp renders GS1 group separator as "<GS>"; normalise to raw byte.
return results[0].text.replace("<GS>", "\x1d")
except ImportError:
pass # fall through to python-zxing

# --- legacy path: python-zxing (requires Java) ---
try:
import zxing # type: ignore[import-not-found]
except ImportError as exc:
raise RuntimeError(
"Decode support requires optional dependency 'zxing' and a Java runtime."
"Decode support requires an optional backend.\n"
" pip install \"aztec-py[decode-fast]\" # zxingcpp — no Java needed\n"
" pip install \"aztec-py[decode]\" # python-zxing — requires Java"
) from exc

reader = zxing.BarCodeReader()
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ pdf = [
decode = [
"zxing>=1.0.4",
]
decode-fast = [
"zxingcpp>=2.2",
"pillow>=8.0",
]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
Expand Down
1 change: 1 addition & 0 deletions scripts/conformance_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def _decode_backend_unavailable(message: str) -> bool:
"optional dependency 'zxing'" in lower
or "java runtime" in lower
or "java" in lower and "failed to decode" in lower
or "optional backend" in lower # zxingcpp-first fallback message
)


Expand Down
1 change: 1 addition & 0 deletions scripts/decoder_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def _decode_backend_unavailable(message: str) -> bool:
"optional dependency 'zxing'" in lower
or "java runtime" in lower
or "java" in lower and "failed to decode" in lower
or "optional backend" in lower # zxingcpp-first fallback message
)


Expand Down
66 changes: 64 additions & 2 deletions tests/test_decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@
from aztec_py import decode


# ---------------------------------------------------------------------------
# Existing tests — zxing legacy path
# (block zxingcpp with None so import raises ImportError, fall through to zxing)
# ---------------------------------------------------------------------------

def test_decode_requires_optional_dependency(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delitem(sys.modules, "zxing", raising=False)
with pytest.raises(RuntimeError, match="optional dependency 'zxing'"):
monkeypatch.setitem(sys.modules, "zxingcpp", None) # blocks re-import
monkeypatch.setitem(sys.modules, "zxing", None) # blocks re-import
with pytest.raises(RuntimeError, match="optional backend"):
decode("missing.png")


Expand All @@ -26,6 +32,7 @@ def decode(self, _source):
class ZXing:
BarCodeReader = Reader

monkeypatch.setitem(sys.modules, "zxingcpp", None) # force zxing path
monkeypatch.setitem(sys.modules, "zxing", ZXing())
assert decode("any.png") == "ok"

Expand All @@ -38,6 +45,61 @@ def decode(self, _source):
class ZXing:
BarCodeReader = Reader

monkeypatch.setitem(sys.modules, "zxingcpp", None) # force zxing path
monkeypatch.setitem(sys.modules, "zxing", ZXing())
with pytest.raises(RuntimeError, match="no payload"):
decode("any.png")


# ---------------------------------------------------------------------------
# New tests — zxingcpp fast path
# Pass a real in-memory PIL image so no file I/O is needed
# ---------------------------------------------------------------------------

def test_decode_zxingcpp_fast_path(monkeypatch: pytest.MonkeyPatch) -> None:
"""zxingcpp is preferred over zxing when present; returns results[0].text."""
from PIL import Image

class FakeResult:
text = "fast-result"

class FakeZxingcpp:
@staticmethod
def read_barcodes(_img: object) -> list[FakeResult]:
return [FakeResult()]

img = Image.new("RGB", (15, 15))
monkeypatch.setitem(sys.modules, "zxingcpp", FakeZxingcpp())
assert decode(img) == "fast-result"


def test_decode_zxingcpp_empty_payload(monkeypatch: pytest.MonkeyPatch) -> None:
"""zxingcpp returning empty list raises RuntimeError."""
from PIL import Image

class FakeZxingcpp:
@staticmethod
def read_barcodes(_img: object) -> list[object]:
return []

img = Image.new("RGB", (15, 15))
monkeypatch.setitem(sys.modules, "zxingcpp", FakeZxingcpp())
with pytest.raises(RuntimeError, match="no payload"):
decode(img)


def test_decode_zxingcpp_falls_back_to_zxing(monkeypatch: pytest.MonkeyPatch) -> None:
"""When zxingcpp is absent, decode falls through to python-zxing."""
class Result:
raw = "zxing-result"

class Reader:
def decode(self, _source: object) -> Result:
return Result()

class ZXing:
BarCodeReader = Reader

monkeypatch.setitem(sys.modules, "zxingcpp", None) # block fast path
monkeypatch.setitem(sys.modules, "zxing", ZXing())
assert decode("any.png") == "zxing-result"
Loading