From c001c05ea7cc82417833d0af5582c305b57a0b22 Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Thu, 28 Aug 2025 13:45:10 +0200 Subject: [PATCH 1/2] Attempt to type-check writers --- barcode/base.py | 24 ++++++++++++++++++------ barcode/writer.py | 34 ++++++++++++++++++++++++++++------ tests/test_writers.py | 3 ++- 3 files changed, 48 insertions(+), 13 deletions(-) diff --git a/barcode/base.py b/barcode/base.py index cfcbe36..e9621c5 100755 --- a/barcode/base.py +++ b/barcode/base.py @@ -4,15 +4,20 @@ from typing import TYPE_CHECKING from typing import ClassVar +from typing import Generic +from typing import TypeVar from barcode.writer import BaseWriter from barcode.writer import SVGWriter +from barcode.writer import T_Output if TYPE_CHECKING: from typing import BinaryIO +W = TypeVar("W", bound=BaseWriter[object]) -class Barcode: + +class Barcode(Generic[W, T_Output]): name = "" digits = 0 @@ -31,9 +36,9 @@ class Barcode: "text": "", } - writer: BaseWriter + writer: W - def __init__(self, code: str, writer: BaseWriter | None = None, **options) -> None: + def __init__(self, code: str, writer: W | None = None, **options) -> None: raise NotImplementedError def to_ascii(self) -> str: @@ -62,7 +67,10 @@ def get_fullcode(self): 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`. @@ -72,7 +80,7 @@ def save( :returns: The full filename with extension. """ - output = self.render(options, text) if text else self.render(options) + output: T_Output = self.render(options, text) if text else self.render(options) return self.writer.save(filename, output) @@ -92,7 +100,11 @@ def write( output = self.render(options, text) self.writer.write(output, fp) - def render(self, writer_options: dict | None = None, text: str | None = None): + def render( + self, + writer_options: dict | None = None, + text: str | None = None, + ) -> T_Output: """Renders the barcode using `self.writer`. :param writer_options: Options for `self.writer`, see writer docs for details. diff --git a/barcode/writer.py b/barcode/writer.py index e5aa647..dac7880 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -3,10 +3,14 @@ import gzip import os import xml.dom.minidom +from abc import ABC +from abc import abstractmethod from typing import TYPE_CHECKING from typing import BinaryIO from typing import Callable +from typing import Generic from typing import TypedDict +from typing import TypeVar from barcode.version import version @@ -78,8 +82,10 @@ 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__)) +T_Output = TypeVar("T_Output") -class BaseWriter: + +class BaseWriter(ABC, Generic[T_Output]): """Baseclass for all writers. Initializes the basic writer options. Child classes can add more attributes and can @@ -164,7 +170,8 @@ 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) -> str: + @abstractmethod + def save(self, filename: str, output: T_Output) -> str: """Saves the rendered output to `filename`. :param filename: Filename without extension. @@ -305,11 +312,12 @@ def render(self, code: list[str]): return self._callbacks["finish"]() + @abstractmethod def write(self, content, fp: BinaryIO) -> None: raise NotImplementedError -class SVGWriter(BaseWriter): +class SVGWriter(BaseWriter[bytes]): def __init__(self) -> None: super().__init__( self._init, @@ -396,7 +404,7 @@ def _finish(self) -> bytes: indent=4 * " ", newl=os.linesep, encoding="UTF-8" ) - def save(self, filename: str, output) -> str: + def save(self, filename: str, output: bytes) -> str: if self.compress: _filename = f"{filename}.svgz" with gzip.open(_filename, "wb") as f: @@ -417,7 +425,21 @@ def write(self, content, fp: BinaryIO) -> None: if Image is None: - ImageWriter: type | None = None + if TYPE_CHECKING: + + class ImageWriter(BaseWriter): + 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, fp: BinaryIO) -> None: ... + else: + ImageWriter = None else: class ImageWriter(BaseWriter): # type: ignore[no-redef] @@ -495,7 +517,7 @@ def _paint_text(self, xpos, ypos): def _finish(self) -> T_Image: return self._image - def save(self, filename: str, output) -> str: + def save(self, filename: str, output: T_Image) -> str: filename = f"{filename}.{self.format.lower()}" output.save(filename, self.format.upper()) return filename diff --git a/tests/test_writers.py b/tests/test_writers.py index 27454bb..2af3b06 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -5,12 +5,13 @@ from barcode import EAN13 from barcode.writer import ImageWriter +from barcode.writer import Image 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 From dab3fdf5befde32efe0e3225eb3f59ab888d6966 Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Mon, 27 Jul 2026 08:30:38 +0200 Subject: [PATCH 2/2] fixup --- barcode/__init__.py | 25 +++++++++++++++---------- barcode/base.py | 33 +++++++++++++++++++++++++-------- barcode/codabar.py | 18 +++++++++++++++--- barcode/codex.py | 41 +++++++++++++++++++++++++++-------------- barcode/ean.py | 38 +++++++++++++++++++++++++++----------- barcode/isxn.py | 24 ++++++++++++++++++------ barcode/itf.py | 24 ++++++++++++++++++++---- barcode/py.typed | 0 barcode/pybarcode.py | 3 ++- barcode/upc.py | 22 ++++++++++++++++++---- barcode/writer.py | 36 ++++++++++++++++++++++-------------- docs/changelog.rst | 2 ++ pyproject.toml | 7 ++++++- tests/test_builds.py | 7 ++++--- tests/test_writers.py | 6 +----- 15 files changed, 202 insertions(+), 84 deletions(-) create mode 100644 barcode/py.typed diff --git a/barcode/__init__.py b/barcode/__init__.py index 6a4d481..898f379 100755 --- a/barcode/__init__.py +++ b/barcode/__init__.py @@ -31,10 +31,12 @@ from barcode.version import version # noqa: F401 if TYPE_CHECKING: + from typing import Any + from barcode.base import Barcode from barcode.writer import BaseWriter -__BARCODE_MAP: dict[str, type[Barcode]] = { +__BARCODE_MAP: dict[str, type[Barcode[Any]]] = { "codabar": CODABAR, "code128": Code128, "code39": Code39, @@ -65,25 +67,28 @@ @overload def get( - name: str, code: str, writer: BaseWriter | None = None, options: dict | None = None -) -> Barcode: ... + name: str, + code: str, + writer: BaseWriter[Any] | None = None, + options: dict | None = None, +) -> Barcode[Any]: ... @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 +101,7 @@ def get( generating. """ options = options or {} - barcode: type[Barcode] + barcode: type[Barcode[Any]] 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 e9621c5..70beea1 100755 --- a/barcode/base.py +++ b/barcode/base.py @@ -5,24 +5,24 @@ from typing import TYPE_CHECKING from typing import ClassVar from typing import Generic -from typing import TypeVar +from typing import cast 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 -W = TypeVar("W", bound=BaseWriter[object]) - -class Barcode(Generic[W, T_Output]): +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, @@ -36,11 +36,28 @@ class Barcode(Generic[W, T_Output]): "text": "", } - writer: W + writer: BaseWriter[T_Output] - def __init__(self, code: str, writer: W | 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 cast("BaseWriter[T_Output]", type(self).default_writer()) + def to_ascii(self) -> str: code_list = self.build() if not len(code_list) == 1: @@ -80,7 +97,7 @@ def save( :returns: The full filename with extension. """ - output: T_Output = self.render(options, text) if text else self.render(options) + output = self.render(options, text) if text else self.render(options) return self.writer.save(filename, output) diff --git a/barcode/codabar.py b/barcode/codabar.py index df0c68b..b31997a 100644 --- a/barcode/codabar.py +++ b/barcode/codabar.py @@ -7,13 +7,19 @@ __docformat__ = "restructuredtext en" +from typing import TYPE_CHECKING + 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] @@ -25,9 +31,15 @@ class CODABAR(Barcode): name = "Codabar (NW-7)" - def __init__(self, code, writer=None, narrow=2, wide=5) -> None: + def __init__( + self, + code, + writer: BaseWriter[T_Output] | None = None, + narrow=2, + wide=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 e22fcd1..ddb78cc 100755 --- a/barcode/codex.py +++ b/barcode/codex.py @@ -14,6 +14,7 @@ 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 @@ -40,12 +41,17 @@ 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" - def __init__(self, code: str, writer=None, add_checksum: bool = True) -> None: + def __init__( + self, + code: str, + writer: BaseWriter[T_Output] | None = None, + add_checksum: bool = True, + ) -> None: r""" :param code: Code 39 string without \* and without checksum. :param writer: A ``barcode.writer`` instance used to render the barcode @@ -56,7 +62,7 @@ def __init__(self, code: str, writer=None, add_checksum: bool = True) -> None: 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: @@ -83,13 +89,17 @@ def build(self) -> list[str]: result = code39.MIDDLE.join(chars) return [result] - def render(self, writer_options=None, text=None): + def render( + self, + writer_options: dict | None = None, + text: str | None = None, + ) -> 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. @@ -100,7 +110,7 @@ class PZN7(Code39): digits = 6 - def __init__(self, pzn, writer=None) -> None: + def __init__(self, pzn, writer: BaseWriter[T_Output] | None = None) -> None: pzn = pzn[: self.digits] if not pzn.isdigit(): raise IllegalCharacterError("PZN can only contain numbers.") @@ -124,13 +134,13 @@ def calculate_checksum(self): 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. @@ -141,12 +151,11 @@ class Code128(Barcode): name = "Code 128" _charset: Literal["A", "B", "C"] code: str - writer: BaseWriter buffer: str - def __init__(self, code: str, writer=None) -> 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) @@ -307,13 +316,17 @@ def build(self) -> list[str]: code += "11" return [code] - def render(self, writer_options=None, text=None): + def render( + self, + writer_options: dict | None = None, + text: str | None = None, + ) -> 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 @@ -325,7 +338,7 @@ class Gs1_128(Code128): # noqa: N801 FNC1_CHAR = "\xf1" - def __init__(self, code, writer=None) -> None: + def __init__(self, code, 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 e7dc5ef..968e357 100755 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -8,11 +8,17 @@ __docformat__ = "restructuredtext en" +from typing import TYPE_CHECKING + 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 barcode.writer import BaseWriter # EAN13 Specs (all sizes in mm) SIZES = { @@ -29,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. @@ -44,7 +50,7 @@ class EuropeanArticleNumber13(Barcode): def __init__( self, ean: str, - writer=None, + writer: BaseWriter[T_Output] | None = None, no_checksum: bool = False, guardbar: bool = False, ) -> None: @@ -75,7 +81,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 @@ -125,22 +131,32 @@ def to_ascii(self) -> str: code = code_list[0] return code.replace("G", "|").replace("1", "|").replace("0", " ") - def render(self, writer_options: dict | None = None, text: str | None = None): + def render( + self, + writer_options: dict | None = None, + text: str | None = None, + ) -> 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" - def __init__(self, ean, writer=None, no_checksum=False, guardbar=True) -> None: + def __init__( + self, + ean, + writer: BaseWriter[T_Output] | None = None, + no_checksum=False, + guardbar=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. @@ -159,7 +175,7 @@ def __init__(self, jan, *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. @@ -190,7 +206,7 @@ def get_fullcode(self): return self.ean -class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): +class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8[T_Output]): """A shortcut to EAN-8 with ``guardbar=True``.""" name = "EAN-8 with guards" @@ -198,14 +214,14 @@ class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): def __init__( self, ean: str, - writer=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 58e11cf..fee64dc 100755 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -24,14 +24,20 @@ from __future__ import annotations +from typing import TYPE_CHECKING + 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 __docformat__ = "restructuredtext en" -class InternationalStandardBookNumber13(EuropeanArticleNumber13): +class InternationalStandardBookNumber13(EuropeanArticleNumber13[T_Output]): """Initializes new ISBN-13 barcode. :param isbn: The isbn number as string. @@ -40,7 +46,13 @@ class InternationalStandardBookNumber13(EuropeanArticleNumber13): name = "ISBN-13" - def __init__(self, isbn, writer=None, no_checksum=False, guardbar=False) -> None: + def __init__( + self, + isbn, + writer: BaseWriter[T_Output] | None = None, + no_checksum=False, + guardbar=False, + ) -> None: isbn = isbn.replace("-", "") self.isbn13 = isbn if isbn[:3] not in ("978", "979"): @@ -50,7 +62,7 @@ def __init__(self, isbn, writer=None, no_checksum=False, guardbar=False) -> None 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. @@ -62,7 +74,7 @@ class InternationalStandardBookNumber10(InternationalStandardBookNumber13): isbn_digits = 9 - def __init__(self, isbn, writer=None) -> None: + def __init__(self, isbn, writer: BaseWriter[T_Output] | None = None) -> None: isbn = isbn.replace("-", "") isbn = isbn[: self.isbn_digits] super().__init__("978" + isbn, writer) @@ -80,7 +92,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. @@ -92,7 +104,7 @@ class InternationalStandardSerialNumber(EuropeanArticleNumber13): issn_digits = 7 - def __init__(self, issn, writer=None) -> None: + def __init__(self, issn, 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 6445d86..b9db5a5 100644 --- a/barcode/itf.py +++ b/barcode/itf.py @@ -7,15 +7,21 @@ __docformat__ = "restructuredtext en" +from typing import TYPE_CHECKING + 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 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 @@ -27,14 +33,20 @@ class ITF(Barcode): name = "ITF" - def __init__(self, code, writer=None, narrow=2, wide=5) -> None: + def __init__( + self, + code, + writer: BaseWriter[T_Output] | None = None, + narrow=2, + wide=5, + ) -> None: if not code.isdigit(): raise IllegalCharacterError("ITF code can only contain numbers.") # Length must be even, prepend 0 if necessary 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 @@ -65,7 +77,11 @@ def build(self) -> list[str]: raw += "0" * self.narrow return [raw] - def render(self, writer_options, text=None): + def render( + self, + writer_options: dict | None = None, + text: str | None = None, + ) -> 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 35d69b0..31c561a 100644 --- a/barcode/pybarcode.py +++ b/barcode/pybarcode.py @@ -2,6 +2,7 @@ import os from argparse import ArgumentParser +from typing import Any import barcode from barcode.version import version @@ -37,7 +38,7 @@ def create_barcode(args, parser) -> 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 060f19f..7941ef0 100755 --- a/barcode/upc.py +++ b/barcode/upc.py @@ -8,14 +8,19 @@ __docformat__ = "restructuredtext en" from functools import reduce +from typing import TYPE_CHECKING 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 barcode.writer import BaseWriter -class UniversalProductCodeA(Barcode): + +class UniversalProductCodeA(Barcode[T_Output]): """Universal Product Code (UPC) barcode. UPC-A consists of 12 numeric digits. @@ -25,7 +30,12 @@ class UniversalProductCodeA(Barcode): digits = 11 - def __init__(self, upc, writer=None, make_ean=False) -> None: + def __init__( + self, + upc, + writer: BaseWriter[T_Output] | None = None, + make_ean=False, + ) -> None: """Initializes new UPC-A barcode. :param str upc: The upc number as string. @@ -45,7 +55,7 @@ def __init__(self, upc, writer=None, make_ean=False) -> None: ) 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: @@ -110,7 +120,11 @@ def to_ascii(self) -> str: code = code_list[0] return code.replace("1", "|").replace("0", "_") - def render(self, writer_options=None, text=None): + def render( + self, + writer_options: dict | None = None, + text: str | None = None, + ) -> 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 dac7880..6f1a74d 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -3,14 +3,11 @@ import gzip import os import xml.dom.minidom -from abc import ABC -from abc import abstractmethod from typing import TYPE_CHECKING from typing import BinaryIO from typing import Callable from typing import Generic from typing import TypedDict -from typing import TypeVar from barcode.version import version @@ -82,10 +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__)) -T_Output = TypeVar("T_Output") +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(ABC, Generic[T_Output]): +class BaseWriter(Generic[T_Output]): """Baseclass for all writers. Initializes the basic writer options. Child classes can add more attributes and can @@ -170,7 +177,6 @@ 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 - @abstractmethod def save(self, filename: str, output: T_Output) -> str: """Saves the rendered output to `filename`. @@ -229,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]): + def render(self, code: list[str]) -> T_Output: """Renders the barcode to whatever the inheriting writer provides, using the registered callbacks. @@ -312,8 +318,7 @@ def render(self, code: list[str]): return self._callbacks["finish"]() - @abstractmethod - def write(self, content, fp: BinaryIO) -> None: + def write(self, content: T_Output, fp: BinaryIO) -> None: raise NotImplementedError @@ -416,7 +421,7 @@ def save(self, filename: str, output: bytes) -> str: f.write(output) return _filename - def write(self, content, fp: BinaryIO) -> None: + def write(self, content: bytes, fp: BinaryIO) -> None: """Write `content` into a file-like object. Content should be a barcode rendered by this writer. @@ -426,8 +431,11 @@ def write(self, content, fp: BinaryIO) -> None: if Image is 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): + class ImageWriter(BaseWriter["T_Image"]): def __init__( self, format: str = "PNG", @@ -437,12 +445,12 @@ def __init__( def save(self, filename: str, output: T_Image) -> str: ... - def write(self, content, fp: BinaryIO) -> None: ... + 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 @@ -522,7 +530,7 @@ def save(self, filename: str, output: T_Image) -> str: output.save(filename, self.format.upper()) return filename - def write(self, content, fp: BinaryIO) -> None: + def write(self, content: T_Image, fp: BinaryIO) -> None: """Write `content` into a file-like object. Content should be a barcode rendered by this writer. diff --git a/docs/changelog.rst b/docs/changelog.rst index caa70d5..d5cf185 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -8,6 +8,8 @@ v0.16.2 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 81cdc66..4b4f536 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,12 +43,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 = [ "E", diff --git a/tests/test_builds.py b/tests/test_builds.py index fb56c23..396f1e9 100755 --- 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 diff --git a/tests/test_writers.py b/tests/test_writers.py index 2af3b06..547d952 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -4,8 +4,8 @@ from io import BytesIO from barcode import EAN13 -from barcode.writer import ImageWriter from barcode.writer import Image +from barcode.writer import ImageWriter from barcode.writer import SVGWriter PATH = os.path.dirname(os.path.abspath(__file__)) @@ -14,8 +14,6 @@ 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) @@ -23,8 +21,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)