Skip to content

Commit b4caceb

Browse files
committed
feat: Java-free decode via zxingcpp fallback (Item 3)
Add zxingcpp as the preferred decode backend (no JVM required), falling back to python-zxing for legacy environments. Normalises zxingcpp's <GS> token to raw \x1d so GS1 group separator payloads round-trip correctly. - decode() tries zxingcpp first (pip install 'aztec-py[decode-fast]') - Falls back to python-zxing if zxingcpp absent - Raises RuntimeError with install instructions if neither is present - New decode-fast extra in pyproject.toml: zxingcpp>=2.2 + pillow - 3 new tests covering fast path, empty result, and fallback behaviour - All 97 tests pass, coverage 91%
1 parent b54360c commit b4caceb

3 files changed

Lines changed: 108 additions & 7 deletions

File tree

aztec_py/decode.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Optional decode utility backed by python-zxing."""
1+
"""Optional decode utility — tries zxingcpp (no Java) then falls back to python-zxing."""
22

33
from __future__ import annotations
44

@@ -8,20 +8,55 @@
88
def decode(source: Any) -> Any:
99
"""Decode an Aztec symbol from an image path or PIL image.
1010
11+
Tries the ``zxingcpp`` backend first (pure C++, no Java runtime required).
12+
Falls back to ``python-zxing`` (requires a JVM) if ``zxingcpp`` is absent.
13+
Raises :class:`RuntimeError` with install instructions if neither is available.
14+
15+
Install the fast backend::
16+
17+
pip install "aztec-py[decode-fast]" # zxingcpp — no Java needed
18+
19+
Install the legacy backend::
20+
21+
pip install "aztec-py[decode]" # python-zxing — requires Java
22+
1123
Args:
12-
source: File path, file object, or PIL image supported by ``python-zxing``.
24+
source: File path (``str`` / ``pathlib.Path``) or PIL ``Image`` object.
1325
1426
Returns:
15-
Decoded payload (`str` or `bytes` depending on the decoder/runtime).
27+
Decoded payload string.
1628
1729
Raises:
18-
RuntimeError: If optional decode dependencies are missing or decode fails.
30+
RuntimeError: If no decode backend is installed or decode fails.
1931
"""
32+
# --- fast path: zxingcpp (C++, no JVM) ---
33+
try:
34+
import zxingcpp # type: ignore[import-not-found]
35+
36+
try:
37+
import PIL.Image as _PILImage
38+
except ImportError as exc:
39+
raise RuntimeError(
40+
"zxingcpp requires Pillow: pip install 'aztec-py[decode-fast]'"
41+
) from exc
42+
43+
img = source if isinstance(source, _PILImage.Image) else _PILImage.open(source)
44+
results = zxingcpp.read_barcodes(img)
45+
if not results:
46+
raise RuntimeError("Decoder returned no payload.")
47+
# zxingcpp renders GS1 group separator as "<GS>"; normalise to raw byte.
48+
return results[0].text.replace("<GS>", "\x1d")
49+
except ImportError:
50+
pass # fall through to python-zxing
51+
52+
# --- legacy path: python-zxing (requires Java) ---
2053
try:
2154
import zxing # type: ignore[import-not-found]
2255
except ImportError as exc:
2356
raise RuntimeError(
24-
"Decode support requires optional dependency 'zxing' and a Java runtime."
57+
"Decode support requires an optional backend.\n"
58+
" pip install \"aztec-py[decode-fast]\" # zxingcpp — no Java needed\n"
59+
" pip install \"aztec-py[decode]\" # python-zxing — requires Java"
2560
) from exc
2661

2762
reader = zxing.BarCodeReader()

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ pdf = [
4949
decode = [
5050
"zxing>=1.0.4",
5151
]
52+
decode-fast = [
53+
"zxingcpp>=2.2",
54+
"pillow>=8.0",
55+
]
5256
dev = [
5357
"pytest>=8.0",
5458
"pytest-cov>=5.0",

tests/test_decode.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
from aztec_py import decode
1010

1111

12+
# ---------------------------------------------------------------------------
13+
# Existing tests — zxing legacy path
14+
# (block zxingcpp with None so import raises ImportError, fall through to zxing)
15+
# ---------------------------------------------------------------------------
16+
1217
def test_decode_requires_optional_dependency(monkeypatch: pytest.MonkeyPatch) -> None:
13-
monkeypatch.delitem(sys.modules, "zxing", raising=False)
14-
with pytest.raises(RuntimeError, match="optional dependency 'zxing'"):
18+
monkeypatch.setitem(sys.modules, "zxingcpp", None) # blocks re-import
19+
monkeypatch.setitem(sys.modules, "zxing", None) # blocks re-import
20+
with pytest.raises(RuntimeError, match="optional backend"):
1521
decode("missing.png")
1622

1723

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

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

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

48+
monkeypatch.setitem(sys.modules, "zxingcpp", None) # force zxing path
4149
monkeypatch.setitem(sys.modules, "zxing", ZXing())
4250
with pytest.raises(RuntimeError, match="no payload"):
4351
decode("any.png")
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# New tests — zxingcpp fast path
56+
# Pass a real in-memory PIL image so no file I/O is needed
57+
# ---------------------------------------------------------------------------
58+
59+
def test_decode_zxingcpp_fast_path(monkeypatch: pytest.MonkeyPatch) -> None:
60+
"""zxingcpp is preferred over zxing when present; returns results[0].text."""
61+
from PIL import Image
62+
63+
class FakeResult:
64+
text = "fast-result"
65+
66+
class FakeZxingcpp:
67+
@staticmethod
68+
def read_barcodes(_img: object) -> list[FakeResult]:
69+
return [FakeResult()]
70+
71+
img = Image.new("RGB", (15, 15))
72+
monkeypatch.setitem(sys.modules, "zxingcpp", FakeZxingcpp())
73+
assert decode(img) == "fast-result"
74+
75+
76+
def test_decode_zxingcpp_empty_payload(monkeypatch: pytest.MonkeyPatch) -> None:
77+
"""zxingcpp returning empty list raises RuntimeError."""
78+
from PIL import Image
79+
80+
class FakeZxingcpp:
81+
@staticmethod
82+
def read_barcodes(_img: object) -> list[object]:
83+
return []
84+
85+
img = Image.new("RGB", (15, 15))
86+
monkeypatch.setitem(sys.modules, "zxingcpp", FakeZxingcpp())
87+
with pytest.raises(RuntimeError, match="no payload"):
88+
decode(img)
89+
90+
91+
def test_decode_zxingcpp_falls_back_to_zxing(monkeypatch: pytest.MonkeyPatch) -> None:
92+
"""When zxingcpp is absent, decode falls through to python-zxing."""
93+
class Result:
94+
raw = "zxing-result"
95+
96+
class Reader:
97+
def decode(self, _source: object) -> Result:
98+
return Result()
99+
100+
class ZXing:
101+
BarCodeReader = Reader
102+
103+
monkeypatch.setitem(sys.modules, "zxingcpp", None) # block fast path
104+
monkeypatch.setitem(sys.modules, "zxing", ZXing())
105+
assert decode("any.png") == "zxing-result"

0 commit comments

Comments
 (0)