From 026f493407d9155f60fa3faeddceec1ff5bc740b Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Sat, 10 Oct 2026 01:39:18 +0200 Subject: [PATCH 1/3] Use concrete barcode type in tests These tests use a non-standard property which only exists on this barcode, but get_barcode returns a type-erased barcode type. This usage would not work with any other barcode type, so it's not ideal. Use the concrete barcode type instead. --- tests/test_builds.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_builds.py b/tests/test_builds.py index fb56c23..396f1e9 100644 --- a/tests/test_builds.py +++ b/tests/test_builds.py @@ -1,6 +1,7 @@ from __future__ import annotations from barcode import get_barcode +from barcode.codex import Code128 def test_ean8_builds() -> None: @@ -32,7 +33,7 @@ def test_code128_leading_99_not_stripped() -> None: # number 99, which collided with the TO_C switch marker. ``_try_to_optimize`` # therefore folded it away, silently dropping the leading "99" from the # barcode (e.g. "9912345678" was encoded as "12345678"). - bc = get_barcode("code128", "9912345678") + bc = Code128("9912345678") assert bc.encoded == [_START_C, 99, 12, 34, 56, 78] assert _decode_code128_c(bc.encoded) == "9912345678" @@ -41,7 +42,7 @@ def test_code128_other_leading_pairs_unaffected() -> None: # Pairs whose code number does not collide with a switch code always worked # and must keep working. for code, first_pair in (("0012345678", 0), ("1212345678", 12)): - bc = get_barcode("code128", code) + bc = Code128(code) assert bc.encoded[:2] == [_START_C, first_pair] assert _decode_code128_c(bc.encoded) == code @@ -49,4 +50,4 @@ def test_code128_other_leading_pairs_unaffected() -> None: def test_code128_start_charset_folding_preserved() -> None: # The START-code folding optimisation (START_C + an immediate charset switch # collapsed into a single START code) must still apply for genuine switches. - assert get_barcode("code128", "Wikipedia").encoded[0] == 104 # START_B + assert Code128("Wikipedia").encoded[0] == 104 # START_B From 5b4328dc4bb934379709939d7f386034d787006b Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Thu, 28 Aug 2025 13:45:10 +0200 Subject: [PATCH 2/3] Type-check writers Make writers generic, since both subclasses produce barcodes of different types. Trickle this to all affected interfaces, so that when using an ImageWriter, the return type is Image, rather than `Image | bytes`. This improves correctness downstream, since consumers now get the right type depending on the writer they're using. The actual API remains unchanged. Changes to tests/test_builds.py are because that test uses a property specific to Code128. --- barcode/__init__.py | 25 ++++++++------ barcode/base.py | 55 ++++++++++++++++++++++++----- barcode/codabar.py | 26 ++++++++++++-- barcode/codex.py | 68 +++++++++++++++++++++++++++--------- barcode/ean.py | 80 ++++++++++++++++++++++++++++++++++++------- barcode/isxn.py | 44 ++++++++++++++++++++---- barcode/itf.py | 30 ++++++++++++---- barcode/py.typed | 0 barcode/pybarcode.py | 3 +- barcode/upc.py | 36 +++++++++++++------ barcode/writer.py | 44 ++++++++++++++++++++---- docs/changelog.rst | 2 ++ pyproject.toml | 7 +++- tests/test_writers.py | 28 ++++++++++++--- tox.ini | 10 +++++- 15 files changed, 370 insertions(+), 88 deletions(-) create mode 100644 barcode/py.typed diff --git a/barcode/__init__.py b/barcode/__init__.py index 6a4d481..e6c56aa 100644 --- a/barcode/__init__.py +++ b/barcode/__init__.py @@ -31,10 +31,13 @@ from barcode.version import version # noqa: F401 if TYPE_CHECKING: + from typing import Any + from barcode.base import Barcode from barcode.writer import BaseWriter + from barcode.writer import T_Output -__BARCODE_MAP: dict[str, type[Barcode]] = { +__BARCODE_MAP: dict[str, type[Barcode[Any]]] = { "codabar": CODABAR, "code128": Code128, "code39": Code39, @@ -65,25 +68,28 @@ @overload def get( - name: str, code: str, writer: BaseWriter | None = None, options: dict | None = None -) -> Barcode: ... + name: str, + code: str, + writer: BaseWriter[T_Output] | None = None, + options: dict | None = None, +) -> Barcode[T_Output]: ... @overload def get( name: str, code: None = None, - writer: BaseWriter | None = None, + writer: BaseWriter[Any] | None = None, options: dict | None = None, -) -> type[Barcode]: ... +) -> type[Barcode[Any]]: ... def get( name: str, code: str | None = None, - writer: BaseWriter | None = None, + writer: BaseWriter[Any] | None = None, options: dict | None = None, -) -> Barcode | type[Barcode]: +) -> Barcode[Any] | type[Barcode[Any]]: """Helper method for getting a generator or even a generated code. :param name: The name of the type of barcode desired. @@ -96,7 +102,6 @@ def get( generating. """ options = options or {} - barcode: type[Barcode] try: barcode = __BARCODE_MAP[name.lower()] except KeyError as e: @@ -107,14 +112,14 @@ def get( return barcode -def get_class(name: str) -> type[Barcode]: +def get_class(name: str) -> type[Barcode[Any]]: return get_barcode(name) def generate( name: str, code: str, - writer: BaseWriter | None = None, + writer: BaseWriter[Any] | None = None, output: str | os.PathLike | BinaryIO | None = None, writer_options: dict | None = None, text: str | None = None, diff --git a/barcode/base.py b/barcode/base.py index e3aa713..855ddef 100644 --- a/barcode/base.py +++ b/barcode/base.py @@ -4,22 +4,25 @@ from typing import TYPE_CHECKING from typing import ClassVar +from typing import Generic +from typing import overload from barcode.writer import BaseWriter from barcode.writer import SVGWriter +from barcode.writer import T_Output if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any from typing import BinaryIO - from PIL.Image import Image as T_Image - -class Barcode: +class Barcode(Generic[T_Output]): name = "" digits = 0 - default_writer = SVGWriter + default_writer: ClassVar[Callable[[], BaseWriter[Any]]] = SVGWriter default_writer_options: ClassVar[dict] = { "module_width": 0.2, @@ -33,11 +36,44 @@ class Barcode: "text": "", } - writer: BaseWriter + writer: BaseWriter[T_Output] + + @overload + def __init__( + self, + code: str, + writer: None = None, + **options, + ) -> None: ... + + @overload + def __init__( + self, + code: str, + writer: BaseWriter[T_Output], + **options, + ) -> None: ... - def __init__(self, code: str, writer: BaseWriter | None = None, **options) -> None: + def __init__( + self, + code: str, + writer: BaseWriter[T_Output] | None = None, + **options, + ) -> None: raise NotImplementedError + def _resolve_writer( + self, + writer: BaseWriter[T_Output] | None, + ) -> BaseWriter[T_Output]: + if writer is not None: + return writer + # When no writer is given, T_Output falls back to its default (bytes), + # which matches what the default writer (SVGWriter) renders. Accessing + # default_writer through the class keeps mypy from binding it like a + # method. + return type(self).default_writer() + def to_ascii(self) -> str: code_list = self.build() if not len(code_list) == 1: @@ -64,7 +100,10 @@ def get_fullcode(self) -> str: raise NotImplementedError def save( - self, filename: str, options: dict | None = None, text: str | None = None + self, + filename: str, + options: dict | None = None, + text: str | None = None, ) -> str: """Renders the barcode and saves it in `filename`. @@ -98,7 +137,7 @@ def render( self, writer_options: dict | None = None, text: str | None = None, - ) -> bytes | T_Image: + ) -> T_Output: """Renders the barcode using `self.writer`. :param writer_options: Options for `self.writer`, see writer docs for details. diff --git a/barcode/codabar.py b/barcode/codabar.py index 3ea73cb..86a1f60 100644 --- a/barcode/codabar.py +++ b/barcode/codabar.py @@ -8,17 +8,19 @@ __docformat__ = "restructuredtext en" from typing import TYPE_CHECKING +from typing import overload from barcode.base import Barcode from barcode.charsets import codabar from barcode.errors import BarcodeError from barcode.errors import IllegalCharacterError +from barcode.writer import T_Output if TYPE_CHECKING: from barcode.writer import BaseWriter -class CODABAR(Barcode): +class CODABAR(Barcode[T_Output]): """Initializes a new CODABAR instance. :param code: Codabar (NW-7) string that matches [ABCD][0-9$:/.+-]+[ABCD] @@ -30,15 +32,33 @@ class CODABAR(Barcode): name = "Codabar (NW-7)" + @overload def __init__( self, code: str, - writer: BaseWriter | None = None, + writer: None = None, + narrow: int = 2, + wide: int = 5, + ) -> None: ... + + @overload + def __init__( + self, + code: str, + writer: BaseWriter[T_Output], + narrow: int = 2, + wide: int = 5, + ) -> None: ... + + def __init__( + self, + code: str, + writer: BaseWriter[T_Output] | None = None, narrow: int = 2, wide: int = 5, ) -> None: self.code = code - self.writer = writer or self.default_writer() + self.writer = self._resolve_writer(writer) self.narrow = narrow self.wide = wide diff --git a/barcode/codex.py b/barcode/codex.py index fe82744..0ecdb27 100644 --- a/barcode/codex.py +++ b/barcode/codex.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from typing import Literal +from typing import overload from barcode.base import Barcode from barcode.charsets import code39 @@ -14,12 +15,11 @@ from barcode.errors import BarcodeError from barcode.errors import IllegalCharacterError from barcode.errors import NumberOfDigitsError +from barcode.writer import T_Output if TYPE_CHECKING: from collections.abc import Collection - from PIL.Image import Image as T_Image - from barcode.writer import BaseWriter __docformat__ = "restructuredtext en" @@ -42,15 +42,31 @@ def check_code(code: str, name: str, allowed: Collection[str]) -> None: ) -class Code39(Barcode): +class Code39(Barcode[T_Output]): """A Code39 barcode implementation""" name = "Code 39" + @overload def __init__( self, code: str, - writer: BaseWriter | None = None, + writer: None = None, + add_checksum: bool = True, + ) -> None: ... + + @overload + def __init__( + self, + code: str, + writer: BaseWriter[T_Output], + add_checksum: bool = True, + ) -> None: ... + + def __init__( + self, + code: str, + writer: BaseWriter[T_Output] | None = None, add_checksum: bool = True, ) -> None: r""" @@ -63,7 +79,7 @@ def __init__( self.code = code.upper() if add_checksum: self.code += self.calculate_checksum() - self.writer = writer or self.default_writer() + self.writer = self._resolve_writer(writer) check_code(self.code, self.name, code39.REF) def __str__(self) -> str: @@ -94,13 +110,13 @@ def render( self, writer_options: dict | None = None, text: str | None = None, - ) -> bytes | T_Image: + ) -> T_Output: options = {"module_width": MIN_SIZE, "quiet_zone": MIN_QUIET_ZONE} options.update(writer_options or {}) return super().render(options, text) -class PZN7(Code39): +class PZN7(Code39[T_Output]): """Initializes new German number for pharmaceutical products. :param pzn: Code to render. @@ -111,7 +127,13 @@ class PZN7(Code39): digits = 6 - def __init__(self, pzn: str, writer: BaseWriter | None = None) -> None: + @overload + def __init__(self, pzn: str, writer: None = None) -> None: ... + + @overload + def __init__(self, pzn: str, writer: BaseWriter[T_Output]) -> None: ... + + def __init__(self, pzn: str, writer: BaseWriter[T_Output] | None = None) -> None: pzn = pzn[: self.digits] if not pzn.isdigit(): raise IllegalCharacterError("PZN can only contain numbers.") @@ -126,7 +148,8 @@ def __init__(self, pzn: str, writer: BaseWriter | None = None) -> None: def get_fullcode(self) -> str: return f"PZN-{self.pzn}" - def calculate_checksum(self) -> int: + # PZN checksums are numeric, unlike Code39's alphanumeric checksum char. + def calculate_checksum(self) -> int: # type: ignore[override] sum_ = sum(int(x) * int(y) for x, y in enumerate(self.pzn, start=2)) checksum = sum_ % 11 if checksum == 10: @@ -135,13 +158,13 @@ def calculate_checksum(self) -> int: return checksum -class PZN8(PZN7): +class PZN8(PZN7[T_Output]): """Will be fully added in v0.9.""" digits = 7 -class Code128(Barcode): +class Code128(Barcode[T_Output]): """Initializes a new Code128 instance. The checksum is added automatically when building the bars. @@ -152,12 +175,17 @@ class Code128(Barcode): name = "Code 128" _charset: Literal["A", "B", "C"] code: str - writer: BaseWriter buffer: str - def __init__(self, code: str, writer: BaseWriter | None = None) -> None: + @overload + def __init__(self, code: str, writer: None = None) -> None: ... + + @overload + def __init__(self, code: str, writer: BaseWriter[T_Output]) -> None: ... + + def __init__(self, code: str, writer: BaseWriter[T_Output] | None = None) -> None: self.code = code - self.writer = writer or self.default_writer() + self.writer = self._resolve_writer(writer) self._charset = "C" self._digit_buffer = "" # Accumulate pairs of digits for charset C check_code(self.code, self.name, code128.ALL) @@ -322,13 +350,13 @@ def render( self, writer_options: dict | None = None, text: str | None = None, - ) -> bytes | T_Image: + ) -> T_Output: options = {"module_width": MIN_SIZE, "quiet_zone": MIN_QUIET_ZONE} options.update(writer_options or {}) return super().render(options, text) -class Gs1_128(Code128): # noqa: N801 +class Gs1_128(Code128[T_Output]): # noqa: N801 """ following the norm, a gs1-128 barcode is a subset of code 128 barcode, it can be generated by prepending the code with the FNC1 character @@ -340,7 +368,13 @@ class Gs1_128(Code128): # noqa: N801 FNC1_CHAR = "\xf1" - def __init__(self, code: str, writer: BaseWriter | None = None) -> None: + @overload + def __init__(self, code: str, writer: None = None) -> None: ... + + @overload + def __init__(self, code: str, writer: BaseWriter[T_Output]) -> None: ... + + def __init__(self, code: str, writer: BaseWriter[T_Output] | None = None) -> None: code = self.FNC1_CHAR + code super().__init__(code, writer) diff --git a/barcode/ean.py b/barcode/ean.py index 85a4e4a..11265c0 100644 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -8,16 +8,16 @@ __docformat__ = "restructuredtext en" from typing import TYPE_CHECKING +from typing import overload from barcode.base import Barcode from barcode.charsets import ean as _ean from barcode.errors import IllegalCharacterError from barcode.errors import NumberOfDigitsError from barcode.errors import WrongCountryCodeError +from barcode.writer import T_Output if TYPE_CHECKING: - from PIL.Image import Image as T_Image - from barcode.writer import BaseWriter # EAN13 Specs (all sizes in mm) @@ -35,7 +35,7 @@ } -class EuropeanArticleNumber13(Barcode): +class EuropeanArticleNumber13(Barcode[T_Output]): """Initializes EAN13 object. :param ean: The ean number as string. If the value is too long, it is trimmed. @@ -47,10 +47,28 @@ class EuropeanArticleNumber13(Barcode): digits = 12 + @overload + def __init__( + self, + ean: str, + writer: None = None, + no_checksum: bool = False, + guardbar: bool = False, + ) -> None: ... + + @overload def __init__( self, ean: str, - writer: BaseWriter | None = None, + writer: BaseWriter[T_Output], + no_checksum: bool = False, + guardbar: bool = False, + ) -> None: ... + + def __init__( + self, + ean: str, + writer: BaseWriter[T_Output] | None = None, no_checksum: bool = False, guardbar: bool = False, ) -> None: @@ -81,7 +99,7 @@ def __init__( else: self.EDGE = _ean.EDGE self.MIDDLE = _ean.MIDDLE - self.writer = writer or self.default_writer() + self.writer = self._resolve_writer(writer) def __str__(self) -> str: return self.ean @@ -135,28 +153,46 @@ def render( self, writer_options: dict | None = None, text: str | None = None, - ) -> bytes | T_Image: + ) -> T_Output: options = {"module_width": SIZES["SC2"]} options.update(writer_options or {}) return super().render(options, text) -class EuropeanArticleNumber13WithGuard(EuropeanArticleNumber13): +class EuropeanArticleNumber13WithGuard(EuropeanArticleNumber13[T_Output]): """A shortcut to EAN-13 with ``guardbar=True``.""" name = "EAN-13 with guards" + @overload + def __init__( + self, + ean: str, + writer: None = None, + no_checksum: bool = False, + guardbar: bool = True, + ) -> None: ... + + @overload def __init__( self, ean: str, - writer: BaseWriter | None = None, + writer: BaseWriter[T_Output], + no_checksum: bool = False, + guardbar: bool = True, + ) -> None: ... + + def __init__( + self, + ean: str, + writer: BaseWriter[T_Output] | None = None, no_checksum: bool = False, guardbar: bool = True, ) -> None: super().__init__(ean, writer, no_checksum, guardbar) -class JapanArticleNumber(EuropeanArticleNumber13): +class JapanArticleNumber(EuropeanArticleNumber13[T_Output]): """Initializes JAN barcode. :param jan: The jan number as string. @@ -175,7 +211,7 @@ def __init__(self, jan: str, *args, **kwargs) -> None: super().__init__(jan, *args, **kwargs) -class EuropeanArticleNumber8(EuropeanArticleNumber13): +class EuropeanArticleNumber8(EuropeanArticleNumber13[T_Output]): """Represents an EAN-8 barcode. See EAN13's __init__ for details. :param ean: The ean number as string. @@ -206,22 +242,40 @@ def get_fullcode(self) -> str: return self.ean -class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): +class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8[T_Output]): """A shortcut to EAN-8 with ``guardbar=True``.""" name = "EAN-8 with guards" + @overload + def __init__( + self, + ean: str, + writer: None = None, + no_checksum: bool = False, + guardbar: bool = True, + ) -> None: ... + + @overload + def __init__( + self, + ean: str, + writer: BaseWriter[T_Output], + no_checksum: bool = False, + guardbar: bool = True, + ) -> None: ... + def __init__( self, ean: str, - writer: BaseWriter | None = None, + writer: BaseWriter[T_Output] | None = None, no_checksum: bool = False, guardbar: bool = True, ) -> None: super().__init__(ean, writer, no_checksum, guardbar) -class EuropeanArticleNumber14(EuropeanArticleNumber13): +class EuropeanArticleNumber14(EuropeanArticleNumber13[T_Output]): """Represents an EAN-14 barcode. See EAN13's __init__ for details. :param ean: The ean number as string. diff --git a/barcode/isxn.py b/barcode/isxn.py index 9405519..e739188 100644 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -25,10 +25,12 @@ from __future__ import annotations from typing import TYPE_CHECKING +from typing import overload from barcode.ean import EuropeanArticleNumber13 from barcode.errors import BarcodeError from barcode.errors import WrongCountryCodeError +from barcode.writer import T_Output if TYPE_CHECKING: from barcode.writer import BaseWriter @@ -36,7 +38,7 @@ __docformat__ = "restructuredtext en" -class InternationalStandardBookNumber13(EuropeanArticleNumber13): +class InternationalStandardBookNumber13(EuropeanArticleNumber13[T_Output]): """Initializes new ISBN-13 barcode. :param isbn: The isbn number as string. @@ -45,10 +47,28 @@ class InternationalStandardBookNumber13(EuropeanArticleNumber13): name = "ISBN-13" + @overload def __init__( self, isbn: str, - writer: BaseWriter | None = None, + writer: None = None, + no_checksum: bool = False, + guardbar: bool = False, + ) -> None: ... + + @overload + def __init__( + self, + isbn: str, + writer: BaseWriter[T_Output], + no_checksum: bool = False, + guardbar: bool = False, + ) -> None: ... + + def __init__( + self, + isbn: str, + writer: BaseWriter[T_Output] | None = None, no_checksum: bool = False, guardbar: bool = False, ) -> None: @@ -61,7 +81,7 @@ def __init__( super().__init__(isbn, writer, no_checksum, guardbar) -class InternationalStandardBookNumber10(InternationalStandardBookNumber13): +class InternationalStandardBookNumber10(InternationalStandardBookNumber13[T_Output]): """Initializes new ISBN-10 barcode. This code is rendered as EAN-13 by prefixing it with 978. @@ -73,7 +93,13 @@ class InternationalStandardBookNumber10(InternationalStandardBookNumber13): isbn_digits = 9 - def __init__(self, isbn: str, writer: BaseWriter | None = None) -> None: + @overload + def __init__(self, isbn: str, writer: None = None) -> None: ... + + @overload + def __init__(self, isbn: str, writer: BaseWriter[T_Output]) -> None: ... + + def __init__(self, isbn: str, writer: BaseWriter[T_Output] | None = None) -> None: isbn = isbn.replace("-", "") isbn = isbn[: self.isbn_digits] super().__init__("978" + isbn, writer) @@ -91,7 +117,7 @@ def __str__(self) -> str: return self.isbn10 -class InternationalStandardSerialNumber(EuropeanArticleNumber13): +class InternationalStandardSerialNumber(EuropeanArticleNumber13[T_Output]): """Initializes new ISSN barcode. This code is rendered as EAN-13 by prefixing it with 977 and adding 00 between code and checksum. @@ -103,7 +129,13 @@ class InternationalStandardSerialNumber(EuropeanArticleNumber13): issn_digits = 7 - def __init__(self, issn: str, writer: BaseWriter | None = None) -> None: + @overload + def __init__(self, issn: str, writer: None = None) -> None: ... + + @overload + def __init__(self, issn: str, writer: BaseWriter[T_Output]) -> None: ... + + def __init__(self, issn: str, writer: BaseWriter[T_Output] | None = None) -> None: issn = issn.replace("-", "") issn = issn[: self.issn_digits] self.issn = issn diff --git a/barcode/itf.py b/barcode/itf.py index 0bb0a0a..0980208 100644 --- a/barcode/itf.py +++ b/barcode/itf.py @@ -8,21 +8,21 @@ __docformat__ = "restructuredtext en" from typing import TYPE_CHECKING +from typing import overload from barcode.base import Barcode from barcode.charsets import itf from barcode.errors import IllegalCharacterError +from barcode.writer import T_Output if TYPE_CHECKING: - from PIL.Image import Image as T_Image - from barcode.writer import BaseWriter MIN_SIZE = 0.2 MIN_QUIET_ZONE = 6.4 -class ITF(Barcode): +class ITF(Barcode[T_Output]): """Initializes a new ITF instance. :param code: ITF (Interleaved 2 of 5) numeric string @@ -34,10 +34,28 @@ class ITF(Barcode): name = "ITF" + @overload + def __init__( + self, + code: str, + writer: None = None, + narrow: int = 2, + wide: int = 5, + ) -> None: ... + + @overload + def __init__( + self, + code: str, + writer: BaseWriter[T_Output], + narrow: int = 2, + wide: int = 5, + ) -> None: ... + def __init__( self, code: str, - writer: BaseWriter | None = None, + writer: BaseWriter[T_Output] | None = None, narrow: int = 2, wide: int = 5, ) -> None: @@ -47,7 +65,7 @@ def __init__( if len(code) % 2 != 0: code = "0" + code self.code = code - self.writer = writer or self.default_writer() + self.writer = self._resolve_writer(writer) self.narrow = narrow self.wide = wide @@ -82,7 +100,7 @@ def render( self, writer_options: dict | None = None, text: str | None = None, - ) -> bytes | T_Image: + ) -> T_Output: options = { "module_width": MIN_SIZE / self.narrow, "quiet_zone": MIN_QUIET_ZONE, diff --git a/barcode/py.typed b/barcode/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/barcode/pybarcode.py b/barcode/pybarcode.py index 7b233d2..ca326fc 100644 --- a/barcode/pybarcode.py +++ b/barcode/pybarcode.py @@ -3,6 +3,7 @@ import os from argparse import ArgumentParser from argparse import Namespace +from typing import Any import barcode from barcode.version import version @@ -38,7 +39,7 @@ def create_barcode(args: Namespace, parser: ArgumentParser) -> None: if args.type != "SVG": assert ImageWriter is not None opts = {"format": args.type} - writer: BaseWriter = ImageWriter() + writer: BaseWriter[Any] = ImageWriter() else: opts = {"compress": args.compress} writer = SVGWriter() diff --git a/barcode/upc.py b/barcode/upc.py index 42938d5..4d574cc 100644 --- a/barcode/upc.py +++ b/barcode/upc.py @@ -9,19 +9,19 @@ from functools import reduce from typing import TYPE_CHECKING +from typing import overload from barcode.base import Barcode from barcode.charsets import upc as _upc from barcode.errors import IllegalCharacterError from barcode.errors import NumberOfDigitsError +from barcode.writer import T_Output if TYPE_CHECKING: - from PIL.Image import Image as T_Image - from barcode.writer import BaseWriter -class UniversalProductCodeA(Barcode): +class UniversalProductCodeA(Barcode[T_Output]): """Universal Product Code (UPC) barcode. UPC-A consists of 12 numeric digits. @@ -31,10 +31,26 @@ class UniversalProductCodeA(Barcode): digits = 11 + @overload + def __init__( + self, + upc: str, + writer: None = None, + make_ean: bool = False, + ) -> None: ... + + @overload + def __init__( + self, + upc: str, + writer: BaseWriter[T_Output], + make_ean: bool = False, + ) -> None: ... + def __init__( self, upc: str, - writer: BaseWriter | None = None, + writer: BaseWriter[T_Output] | None = None, make_ean: bool = False, ) -> None: """Initializes new UPC-A barcode. @@ -56,7 +72,7 @@ def __init__( ) self.upc = upc self.upc = f"{upc}{self.calculate_checksum()}" - self.writer = writer or self.default_writer() + self.writer = self._resolve_writer(writer) def __str__(self) -> str: if self.ean: @@ -77,12 +93,12 @@ def calculate_checksum(self) -> int: :rtype: int """ - def sum_(x: str, y: str) -> int: - return int(x) + int(y) + def sum_(x: int, y: str) -> int: + return x + int(y) upc = self.upc[0 : self.digits] - oddsum = reduce(sum_, upc[::2]) - evensum = reduce(sum_, upc[1::2]) + oddsum = reduce(sum_, upc[::2], 0) + evensum = reduce(sum_, upc[1::2], 0) check = (evensum + oddsum * 3) % 10 if check == 0: return 0 @@ -125,7 +141,7 @@ def render( self, writer_options: dict | None = None, text: str | None = None, - ) -> bytes | T_Image: + ) -> T_Output: options = {"module_width": 0.33} options.update(writer_options or {}) return super().render(options, text) diff --git a/barcode/writer.py b/barcode/writer.py index ca8a02d..114e344 100644 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -5,6 +5,7 @@ import xml.dom.minidom from typing import TYPE_CHECKING from typing import BinaryIO +from typing import Generic from typing import TypedDict from barcode.version import version @@ -78,8 +79,20 @@ def create_svg_object(with_doctype: bool = False) -> xml.dom.minidom.Document: COMMENT = f"Autogenerated with python-barcode {version}" PATH = os.path.dirname(os.path.abspath(__file__)) +if TYPE_CHECKING: + # Typevar defaults (PEP 696) only exist at runtime on Python 3.13+, so use + # the typing_extensions variant for type-checkers only; this way it is not + # a runtime dependency. + from typing_extensions import TypeVar + + T_Output = TypeVar("T_Output", default=bytes) +else: + from typing import TypeVar + + T_Output = TypeVar("T_Output") + -class BaseWriter: +class BaseWriter(Generic[T_Output]): """Baseclass for all writers. Initializes the basic writer options. Child classes can add more attributes and can @@ -164,7 +177,7 @@ def calculate_size(self, modules_per_line: int, number_of_lines: int) -> tuple: height += self.text_line_distance * (number_of_text_lines - 1) return width, height - def save(self, filename: str, output: bytes | T_Image) -> str: + def save(self, filename: str, output: T_Output) -> str: """Saves the rendered output to `filename`. :param filename: Filename without extension. @@ -222,7 +235,7 @@ def packed(self, line: str) -> Generator[tuple[int, float], str, None]: yield (-c, self.guard_height_factor) c = 1 - def render(self, code: list[str]) -> bytes | T_Image: + def render(self, code: list[str]) -> T_Output: """Renders the barcode to whatever the inheriting writer provides, using the registered callbacks. @@ -305,11 +318,11 @@ def render(self, code: list[str]) -> bytes | T_Image: return self._callbacks["finish"]() - def write(self, content: bytes | T_Image, fp: BinaryIO) -> None: + def write(self, content: T_Output, fp: BinaryIO) -> None: raise NotImplementedError -class SVGWriter(BaseWriter): +class SVGWriter(BaseWriter[bytes]): def __init__(self) -> None: super().__init__( self._init, @@ -423,10 +436,27 @@ def write(self, content: bytes, fp: BinaryIO) -> None: if Image is None: - ImageWriter: type | None = None + if TYPE_CHECKING: + # Keep this stub's signatures in sync with the real class below. It + # exists so that type-checkers see a single, always-defined class + # regardless of whether Pillow is installed. + + class ImageWriter(BaseWriter["T_Image"]): + def __init__( + self, + format: str = "PNG", + mode: str = "RGB", + dpi: int = 300, + ) -> None: ... + + def save(self, filename: str, output: T_Image) -> str: ... + + def write(self, content: T_Image, fp: BinaryIO) -> None: ... + else: + ImageWriter = None else: - class ImageWriter(BaseWriter): # type: ignore[no-redef] + class ImageWriter(BaseWriter["T_Image"]): # type: ignore[no-redef] format: str mode: str dpi: int diff --git a/docs/changelog.rst b/docs/changelog.rst index 9a15b52..56266c6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,8 @@ v0.17.0 barcodes. (#251) * Fix ISBN-10 and ISSN barcodes being truncated to 10 and 8 digits. * Fix Code128 emitting different (but valid) output depending on invocation. +* Add type annotations for writers and barcode classes. Both are now generic + over the writer's output type, and the package ships a ``py.typed`` marker. v0.16.1 ~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index 13cc895..f903a36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,12 +42,17 @@ issues = "https://github.com/WhyNotHugo/python-barcode/issues" funding= "https://whynothugo.nl/sponsor/" [tool.setuptools.package-data] -barcode = ["barcode/fonts/*.ttf"] +barcode = ["barcode/fonts/*.ttf", "py.typed"] [tool.setuptools_scm] write_to = "barcode/version.py" version_scheme = "post-release" +# Pillow is an optional dependency; the plain mypy environment runs without it. +[[tool.mypy.overrides]] +module = "PIL.*" +ignore_missing_imports = true + [tool.ruff.lint] extend-select = [ "ANN", diff --git a/tests/test_writers.py b/tests/test_writers.py index 27454bb..3515259 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -2,19 +2,19 @@ import os from io import BytesIO +from typing import assert_type from barcode import EAN13 +from barcode.writer import Image from barcode.writer import ImageWriter from barcode.writer import SVGWriter PATH = os.path.dirname(os.path.abspath(__file__)) TESTPATH = os.path.join(PATH, "test_outputs") -if ImageWriter is not None: +if Image is not None: def test_saving_image_to_byteio() -> None: - assert ImageWriter is not None # workaround for mypy - rv = BytesIO() EAN13(str(100000902922), writer=ImageWriter()).write(rv) @@ -22,8 +22,6 @@ def test_saving_image_to_byteio() -> None: EAN13("100000011111", writer=ImageWriter()).write(f) def test_saving_rgba_image() -> None: - assert ImageWriter is not None # workaround for mypy - rv = BytesIO() EAN13(str(100000902922), writer=ImageWriter()).write(rv) @@ -34,6 +32,17 @@ def test_saving_rgba_image() -> None: f, options={"background": "rgba(255,0,0,0)"} ) + def test_none_writer_typed_as_bytes_or_image() -> None: + writer: ImageWriter | None = None + bc = EAN13("100000011111", writer=writer) + assert_type(bc, EAN13[bytes] | EAN13[Image.Image]) + +else: + + def test_none_writer_typed_as_bytes() -> None: + bc = EAN13("100000011111", writer=None) + assert_type(bc, EAN13[bytes]) + def test_saving_svg_to_byteio() -> None: rv = BytesIO() @@ -49,3 +58,12 @@ def test_saving_svg_to_byteio_with_guardbar() -> None: with open(f"{TESTPATH}/somefile_guardbar.svg", "wb") as f: EAN13("100000011111", writer=SVGWriter(), guardbar=True).write(f) + + +def test_none_writer_defaults_to_svg_writer() -> None: + # A None writer, even when statically typed as an image writer, + # still falls back to the default SVG writer at runtime. + writer: ImageWriter | None = None + bc = EAN13("100000011111", writer=writer) + assert isinstance(bc.writer, SVGWriter) + assert isinstance(bc.render(), bytes) diff --git a/tox.ini b/tox.ini index ba2c87a..f7165eb 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,14 @@ usedevelop = True [testenv:mypy] deps = mypy - images: Pillow + pytest +commands = mypy . +usedevelop = True + +[testenv:mypy-images] +deps = + mypy + pytest + Pillow commands = mypy . usedevelop = True From ad100ee7dea29ba2feaaa64ac0e07ac0d2ce29f7 Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Sat, 10 Oct 2026 01:38:17 +0200 Subject: [PATCH 3/3] Simplify sorting barcodes --- barcode/__init__.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/barcode/__init__.py b/barcode/__init__.py index e6c56aa..bbc6b2e 100644 --- a/barcode/__init__.py +++ b/barcode/__init__.py @@ -62,8 +62,7 @@ "upca": UPCA, } -PROVIDED_BARCODES = list(__BARCODE_MAP) -PROVIDED_BARCODES.sort() +PROVIDED_BARCODES = sorted(__BARCODE_MAP) @overload