diff --git a/barcode/__init__.py b/barcode/__init__.py index 6a4d481..bbc6b2e 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, @@ -59,31 +62,33 @@ "upca": UPCA, } -PROVIDED_BARCODES = list(__BARCODE_MAP) -PROVIDED_BARCODES.sort() +PROVIDED_BARCODES = sorted(__BARCODE_MAP) @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 +101,6 @@ def get( generating. """ options = options or {} - barcode: type[Barcode] try: barcode = __BARCODE_MAP[name.lower()] except KeyError as e: @@ -107,14 +111,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_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 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