diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c7077f1..baae952 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,39 +6,16 @@ on: - v* jobs: - github-release: - runs-on: ubuntu-18.04 - name: Publish GitHub Release - steps: - - uses: actions/checkout@master - - uses: actions/setup-python@v1 - with: - python-version: 3.7 - architecture: x64 - - name: Install build dependencies - run: pip install wheel - - name: Build packages - run: python setup.py sdist bdist_wheel - - name: Release - uses: softprops/action-gh-release@v1 - with: - files: dist/* - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} pypi: - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 name: Publish package on PyPI steps: - - uses: actions/checkout@master - - uses: actions/setup-python@v1 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 with: python-version: 3.7 - architecture: x64 - - name: Install build dependencies - run: pip install wheel - - name: Build packages - run: python setup.py sdist bdist_wheel - - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@master + - run: pip install build setuptools wheel build setuptools_scm + - run: python -m build --sdist --wheel --no-isolation + - uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e36f877..31375c4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,22 +7,29 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ ubuntu-20.04, macOS-10.15, windows-2019 ] - python: [ '3.6', '3.7', '3.8', '3.9', '3.10' ] + os: [ ubuntu-22.04 ] + python: [ '3.9', '3.10', '3.11', '3.12', '3.13' ] variant: [ "py", "py-images" ] + include: + - os: macOS-12 + python: "3.12" + variant: py-images + - os: windows-2022 + python: "3.12" + variant: py-images + - os: windows-2022 name: python${{ matrix.python }} on ${{ matrix.os }} ${{ matrix.variant }} steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python }} + cache: pip + cache-dependency-path: | + pyproject.toml - name: Install test dependency - run: pip install tox codecov + run: pip install tox - name: Run tests run: tox env: TOXENV: ${{ matrix.variant }} - - name: Report coverage - run: codecov - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 33f7bda..844a299 100755 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.kpf *.svg *.db +.idea/* barcode/__pycache* build/* dist/* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5fbe2b8..2b932c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,32 +1,19 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v5.0.0 hooks: - id: trailing-whitespace args: [--markdown-linebreak-ext=md] - id: end-of-file-fixer - id: debug-statements - - repo: https://github.com/pycqa/isort - rev: 5.10.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.11.9' hooks: - - id: isort - - repo: https://github.com/psf/black - rev: "22.3.0" + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format + - repo: https://github.com/netromdk/vermin + rev: v1.6.0 hooks: - - id: black - - repo: https://github.com/PyCQA/flake8 - rev: "4.0.1" - hooks: - - id: flake8 - additional_dependencies: - - flake8-comprehensions - - flake8-bugbear - - repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 - hooks: - - id: pyupgrade - args: [--py36-plus] - - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v0.950' - hooks: - - id: mypy + - id: vermin + args: ['-t=3.9-', '--violations'] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..69e83c2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# Contributing to python-barcode + +## Bug reports + +Please make sure that you take the following steps when reporting issues: + +Ensure that you are using the latest version. You bug may already have been +fixed. + +Search existing issues to ensure that it was not previously included. Be sure to +search closed issues too, your issue may have been fixed recently. + +In case that the generated output is not correct, please mention: + +- What input did you provide? +- What output did you get? +- What output do you think you should have obtained? + +In case of crashes, please copy the full stack trace and **at least** the line +from your code that reproduces the crash. + +Trying to understand and debug any error is a lot easier if you provide a small +portion of code that I can run myself to reproduce the error. diff --git a/LICENCE b/LICENCE index 159bbe8..92decff 100644 --- a/LICENCE +++ b/LICENCE @@ -1,4 +1,4 @@ -Copyright (c) 2017-2020 Hugo Osvaldo Barrera , et al +Copyright (c) 2017-2023 Hugo Osvaldo Barrera , et al Copyright (c) 2010-2013 Thorsten Weimann Permission is hereby granted, free of charge, to any person obtaining a copy of diff --git a/README.rst b/README.rst index ab1650e..44b25a2 100644 --- a/README.rst +++ b/README.rst @@ -1,14 +1,10 @@ python-barcode ============== -.. image:: https://action-badges.now.sh/WhyNotHugo/python-barcode +.. image:: https://github.com/WhyNotHugo/python-barcode/actions/workflows/tests.yml/badge.svg :target: https://github.com/WhyNotHugo/python-barcode/actions :alt: CI status -.. image:: https://codecov.io/gh/WhyNotHugo/python-barcode/branch/main/graph/badge.svg - :target: https://codecov.io/gh/WhyNotHugo/python-barcode - :alt: Build coverage - .. image:: https://readthedocs.org/projects/python-barcode/badge/ :target: https://python-barcode.rtfd.org/ :alt: documentation @@ -30,7 +26,7 @@ python-barcode There are no external dependencies when generating SVG files. Pillow is required for generating images (e.g.: PNGs). -Support Python 3.6 to 3.10. +Support Python 3.9 to 3.13. .. image:: example-ean13.png :target: https://github.com/WhyNotHugo/python-barcode @@ -41,6 +37,8 @@ Documentation Full documentation is published at http://python-barcode.rtfd.io/ +You can build the documentation locally using ``make -C docs html``. + Licence ------- diff --git a/barcode/__init__.py b/barcode/__init__.py index a36b840..6a4d481 100755 --- a/barcode/__init__.py +++ b/barcode/__init__.py @@ -3,10 +3,13 @@ created as SVG objects. If Pillow is installed, the barcodes can also be rendered as images (all formats supported by Pillow). """ + +from __future__ import annotations + import os +from typing import TYPE_CHECKING from typing import BinaryIO -from typing import Dict -from typing import Union +from typing import overload from barcode.codabar import CODABAR from barcode.codex import PZN @@ -27,70 +30,95 @@ from barcode.upc import UPCA from barcode.version import version # noqa: F401 -__BARCODE_MAP = { - "ean8": EAN8, - "ean8-guard": EAN8_GUARD, +if TYPE_CHECKING: + from barcode.base import Barcode + from barcode.writer import BaseWriter + +__BARCODE_MAP: dict[str, type[Barcode]] = { + "codabar": CODABAR, + "code128": Code128, + "code39": Code39, + "ean": EAN13, "ean13": EAN13, "ean13-guard": EAN13_GUARD, - "ean": EAN13, - "gtin": EAN14, "ean14": EAN14, - "jan": JAN, - "upc": UPCA, - "upca": UPCA, - "isbn": ISBN13, - "isbn13": ISBN13, + "ean8": EAN8, + "ean8-guard": EAN8_GUARD, "gs1": ISBN13, + "gs1_128": Gs1_128, + "gtin": EAN14, + "isbn": ISBN13, "isbn10": ISBN10, + "isbn13": ISBN13, "issn": ISSN, - "code39": Code39, - "pzn": PZN, - "code128": Code128, "itf": ITF, - "gs1_128": Gs1_128, - "codabar": CODABAR, + "jan": JAN, "nw-7": CODABAR, + "pzn": PZN, + "upc": UPCA, + "upca": UPCA, } PROVIDED_BARCODES = list(__BARCODE_MAP) PROVIDED_BARCODES.sort() -def get(name, code=None, writer=None, options=None): +@overload +def get( + name: str, code: str, writer: BaseWriter | None = None, options: dict | None = None +) -> Barcode: ... + + +@overload +def get( + name: str, + code: None = None, + writer: BaseWriter | None = None, + options: dict | None = None, +) -> type[Barcode]: ... + + +def get( + name: str, + code: str | None = None, + writer: BaseWriter | None = None, + options: dict | None = None, +) -> Barcode | type[Barcode]: """Helper method for getting a generator or even a generated code. - :param str name: The name of the type of barcode desired. - :param str code: The actual information to encode. If this parameter is + :param name: The name of the type of barcode desired. + :param code: The actual information to encode. If this parameter is provided, a generated barcode is returned. Otherwise, the barcode class is returned. :param Writer writer: An alternative writer to use when generating the barcode. - :param dict options: Additional options to be passed on to the barcode when + :param options: Additional options to be passed on to the barcode when generating. """ options = options or {} + barcode: type[Barcode] try: barcode = __BARCODE_MAP[name.lower()] - except KeyError: - raise BarcodeNotFoundError(f"The barcode {name!r} you requested is not known.") + except KeyError as e: + raise BarcodeNotFoundError(f"The barcode {name!r} is not known.") from e if code is not None: return barcode(code, writer, **options) - else: - return barcode + + return barcode -def get_class(name): +def get_class(name: str) -> type[Barcode]: return get_barcode(name) def generate( name: str, code: str, - writer=None, - output: Union[str, os.PathLike, BinaryIO] = None, - writer_options: Dict = None, - text: str = None, -): + writer: BaseWriter | None = None, + output: str | os.PathLike | BinaryIO | None = None, + writer_options: dict | None = None, + text: str | None = None, +) -> str | None: """Shortcut to generate a barcode in one line. :param name: Name of the type of barcode to use. @@ -103,18 +131,22 @@ def generate( """ from barcode.base import Barcode + if output is None: + raise TypeError("'output' cannot be None") + writer = writer or Barcode.default_writer() writer.set_options(writer_options or {}) barcode = get(name, code, writer) if isinstance(output, str): - fullname = barcode.save(output, writer_options, text) - return fullname - elif output: - barcode.write(output, writer_options, text) - else: - raise TypeError("'output' cannot be None") + return barcode.save(output, writer_options, text) + if isinstance(output, os.PathLike): + with open(output, "wb") as fp: + barcode.write(fp, writer_options, text) + return None + barcode.write(output, writer_options, text) + return None get_barcode = get diff --git a/barcode/base.py b/barcode/base.py index 30ae8d4..cfcbe36 100755 --- a/barcode/base.py +++ b/barcode/base.py @@ -1,18 +1,25 @@ -"""barcode.base +"""barcode.base""" -""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import ClassVar + +from barcode.writer import BaseWriter from barcode.writer import SVGWriter +if TYPE_CHECKING: + from typing import BinaryIO -class Barcode: +class Barcode: name = "" digits = 0 default_writer = SVGWriter - default_writer_options = { + default_writer_options: ClassVar[dict] = { "module_width": 0.2, "module_height": 15.0, "quiet_zone": 6.5, @@ -24,16 +31,26 @@ class Barcode: "text": "", } - def to_ascii(self): - code = self.build() - for i, line in enumerate(code): - code[i] = line.replace("1", "X").replace("0", " ") - return "\n".join(code) + writer: BaseWriter - def __repr__(self): + def __init__(self, code: str, writer: BaseWriter | None = None, **options) -> None: + raise NotImplementedError + + def to_ascii(self) -> str: + code_list = self.build() + if not len(code_list) == 1: + raise RuntimeError("Code list must contain a single element.") + code = code_list[0] + return code.replace("1", "X").replace("0", " ") + + def __repr__(self) -> str: return f"<{self.__class__.__name__}({self.get_fullcode()!r})>" - def build(self): + def build(self) -> list[str]: + """Return a single-element list with a string encoding the barcode. + + Typically the string consists of 1s and 0s, although it can contain + other characters such as G for guard lines (e.g. in EAN13).""" raise NotImplementedError def get_fullcode(self): @@ -44,52 +61,42 @@ def get_fullcode(self): """ raise NotImplementedError - def save(self, filename, options=None, text=None): + def save( + self, filename: str, options: dict | None = None, text: str | None = None + ) -> str: """Renders the barcode and saves it in `filename`. - :parameters: - filename : String - Filename to save the barcode in (without filename - extension). - options : Dict - The same as in `self.render`. - text : str - Text to render under the barcode. + :param filename: Filename to save the barcode in (without filename extension). + :param options: The same as in `self.render`. + :param text: Text to render under the barcode. :returns: The full filename with extension. - :rtype: String """ - if text: - output = self.render(options, text) - else: - output = self.render(options) + output = self.render(options, text) if text else self.render(options) - _filename = self.writer.save(filename, output) - return _filename + return self.writer.save(filename, output) - def write(self, fp, options=None, text=None): + def write( + self, + fp: BinaryIO, + options: dict | None = None, + text: str | None = None, + ) -> None: """Renders the barcode and writes it to the file like object `fp`. - :parameters: - fp : File like object - Object to write the raw data in. - options : Dict - The same as in `self.render`. - text : str - Text to render under the barcode. + :param fp: Object to write the raw data in. + :param options: The same as in `self.render`. + :param text: Text to render under the barcode. """ output = self.render(options, text) self.writer.write(output, fp) - def render(self, writer_options=None, text=None): + def render(self, writer_options: dict | None = None, text: str | None = None): """Renders the barcode using `self.writer`. - :parameters: - writer_options : Dict - Options for `self.writer`, see writer docs for details. - text : str - Text to render under the barcode. + :param writer_options: Options for `self.writer`, see writer docs for details. + :param text: Text to render under the barcode. :returns: Output of the writers render method. """ @@ -101,6 +108,8 @@ def render(self, writer_options=None, text=None): else: options["text"] = self.get_fullcode() self.writer.set_options(options) - code = self.build() - raw = self.writer.render(code) - return raw + code_list = self.build() + if not len(code_list) == 1: + raise RuntimeError("Code list must contain a single element.") + code = code_list[0] + return self.writer.render([code]) diff --git a/barcode/charsets/codabar.py b/barcode/charsets/codabar.py index d0ba077..429b3ce 100644 --- a/barcode/charsets/codabar.py +++ b/barcode/charsets/codabar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # W = Wide bar # w = wide space # N = Narrow bar diff --git a/barcode/charsets/code128.py b/barcode/charsets/code128.py index 4941aa2..c631f3b 100644 --- a/barcode/charsets/code128.py +++ b/barcode/charsets/code128.py @@ -1,47 +1,44 @@ +from __future__ import annotations + import string # Charsets for code 128 _common = ( - ( - " ", - "!", - '"', - "#", - "$", - "%", - "&", - "'", - "(", - ")", - "*", - "+", - ",", - "-", - ".", - "/", - ) - + tuple(string.digits) - + ( - ":", - ";", - "<", - "=", - ">", - "?", - "@", - ) - + tuple(string.ascii_uppercase) - + ( - "[", - "\\", - "]", - "^", - "_", - ) + " ", + "!", + '"', + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + *tuple(string.digits), + ":", + ";", + "<", + "=", + ">", + "?", + "@", + *tuple(string.ascii_uppercase), + "[", + "\\", + "]", + "^", + "_", ) -_charset_a = _common + ( +_charset_a = ( + *_common, "\x00", "\x01", "\x02", @@ -51,11 +48,11 @@ "\x06", "\x07", "\x08", - "\x09", - "\x0a", + "\t", + "\n", "\x0b", "\x0c", - "\x0d", + "\r", "\x0e", "\x0f", "\x10", @@ -74,33 +71,31 @@ "\x1d", "\x1e", "\x1f", - "\xf3", - "\xf2", + "ó", + "ò", "SHIFT", "TO_C", "TO_B", - "\xf4", - "\xf1", + "ô", + "ñ", ) _charset_b = ( - _common - + ("`",) - + tuple(string.ascii_lowercase) - + ( - "{", - "|", - "}", - "~", - "\x7f", - "\xf3", - "\xf2", - "SHIFT", - "TO_C", - "\xf4", - "TO_A", - "\xf1", - ) + *_common, + "`", + *tuple(string.ascii_lowercase), + "{", + "|", + "}", + "~", + "\x7f", + "ó", + "ò", + "SHIFT", + "TO_C", + "ô", + "TO_A", + "ñ", ) ALL = set(_common + _charset_a + _charset_b) diff --git a/barcode/charsets/code39.py b/barcode/charsets/code39.py index d9f679d..dcd80e7 100644 --- a/barcode/charsets/code39.py +++ b/barcode/charsets/code39.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import string # Charsets for code 39 diff --git a/barcode/charsets/ean.py b/barcode/charsets/ean.py index 199142d..afc94c1 100644 --- a/barcode/charsets/ean.py +++ b/barcode/charsets/ean.py @@ -1,3 +1,5 @@ +from __future__ import annotations + EDGE = "101" MIDDLE = "01010" CODES = { diff --git a/barcode/charsets/itf.py b/barcode/charsets/itf.py index 24eb02f..6965bae 100644 --- a/barcode/charsets/itf.py +++ b/barcode/charsets/itf.py @@ -1,3 +1,5 @@ +from __future__ import annotations + # W = Wide bar # w = wide space # N = Narrow bar diff --git a/barcode/charsets/upc.py b/barcode/charsets/upc.py index df90deb..cb49510 100644 --- a/barcode/charsets/upc.py +++ b/barcode/charsets/upc.py @@ -1,3 +1,5 @@ +from __future__ import annotations + EDGE = "101" MIDDLE = "01010" CODES = { diff --git a/barcode/codabar.py b/barcode/codabar.py index a2247ca..a70e3dd 100644 --- a/barcode/codabar.py +++ b/barcode/codabar.py @@ -2,6 +2,9 @@ :Provided barcodes: Codabar (NW-7) """ + +from __future__ import annotations + __docformat__ = "restructuredtext en" from barcode.base import Barcode @@ -27,38 +30,40 @@ class CODABAR(Barcode): name = "Codabar (NW-7)" - def __init__(self, code, writer=None, narrow=2, wide=5): + def __init__(self, code, writer=None, narrow=2, wide=5) -> None: self.code = code self.writer = writer or self.default_writer() self.narrow = narrow self.wide = wide - def __str__(self): + def __str__(self) -> str: return self.code def get_fullcode(self): return self.code - def build(self): + def build(self) -> list[str]: try: data = ( codabar.STARTSTOP[self.code[0]] + "n" ) # Start with [A-D], followed by a narrow space except KeyError: - raise BarcodeError("Codabar should start with either A,B,C or D") + raise BarcodeError("Codabar should start with either A,B,C or D") from None try: data += "n".join( [codabar.CODES[c] for c in self.code[1:-1]] ) # separated by a narrow space except KeyError: - raise IllegalCharacterError("Codabar can only contain numerics or $:/.+-") + raise IllegalCharacterError( + "Codabar can only contain numerics or $:/.+-" + ) from None try: data += "n" + codabar.STARTSTOP[self.code[-1]] # End with [A-D] except KeyError: - raise BarcodeError("Codabar should end with either A,B,C or D") + raise BarcodeError("Codabar should end with either A,B,C or D") from None raw = "" for e in data: diff --git a/barcode/codex.py b/barcode/codex.py index 74665be..9a60c31 100755 --- a/barcode/codex.py +++ b/barcode/codex.py @@ -2,6 +2,12 @@ :Provided barcodes: Code 39, Code 128, PZN """ + +from __future__ import annotations + +from typing import TYPE_CHECKING +from typing import Literal + from barcode.base import Barcode from barcode.charsets import code39 from barcode.charsets import code128 @@ -9,6 +15,11 @@ from barcode.errors import IllegalCharacterError from barcode.errors import NumberOfDigitsError +if TYPE_CHECKING: + from collections.abc import Collection + + from barcode.writer import BaseWriter + __docformat__ = "restructuredtext en" # Sizes @@ -16,7 +27,7 @@ MIN_QUIET_ZONE = 2.54 -def check_code(code, name, allowed): +def check_code(code: str, name: str, allowed: Collection[str]) -> None: wrong = [] for char in code: if char not in allowed: @@ -34,7 +45,7 @@ class Code39(Barcode): name = "Code 39" - def __init__(self, code: str, writer=None, add_checksum: bool = True): + def __init__(self, code: str, writer=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 @@ -48,25 +59,29 @@ def __init__(self, code: str, writer=None, add_checksum: bool = True): self.writer = writer or self.default_writer() check_code(self.code, self.name, code39.REF) - def __str__(self): + def __str__(self) -> str: return self.code def get_fullcode(self) -> str: """:returns: The full code as it will be encoded.""" return self.code - def calculate_checksum(self): + def calculate_checksum(self) -> str: check = sum(code39.MAP[x][0] for x in self.code) % 43 for k, v in code39.MAP.items(): if check == v[0]: return k + raise RuntimeError( + "All possible values for the checksum should have been included in the map." + ) - def build(self): + def build(self) -> list[str]: chars = [code39.EDGE] for char in self.code: chars.append(code39.MAP[char][1]) chars.append(code39.EDGE) - return [code39.MIDDLE.join(chars)] + result = code39.MIDDLE.join(chars) + return [result] def render(self, writer_options=None, text=None): options = {"module_width": MIN_SIZE, "quiet_zone": MIN_QUIET_ZONE} @@ -88,7 +103,7 @@ class PZN7(Code39): digits = 6 - def __init__(self, pzn, writer=None): + def __init__(self, pzn, writer=None) -> None: pzn = pzn[: self.digits] if not pzn.isdigit(): raise IllegalCharacterError("PZN can only contain numbers.") @@ -108,8 +123,8 @@ def calculate_checksum(self): checksum = sum_ % 11 if checksum == 10: raise BarcodeError("Checksum can not be 10 for PZN.") - else: - return checksum + + return checksum class PZN8(PZN7): @@ -130,25 +145,31 @@ class Code128(Barcode): """ name = "Code 128" + _charset: Literal["A", "B", "C"] + code: str + writer: BaseWriter + buffer: str - def __init__(self, code, writer=None): + def __init__(self, code: str, writer=None) -> None: self.code = code self.writer = writer or self.default_writer() - self._charset = "B" - self._buffer = "" + self._charset = "C" + self._digit_buffer = "" # Accumulate pairs of digits for charset C check_code(self.code, self.name, code128.ALL) - def __str__(self): + def __str__(self) -> str: return self.code @property - def encoded(self): + def encoded(self) -> list[int]: return self._build() - def get_fullcode(self): + def get_fullcode(self) -> str: return self.code - def _new_charset(self, which): + def _new_charset(self, which: Literal["A", "B", "C"]) -> list[int]: + if which == self._charset: + raise ValueError(f"Already in charset {which}") if which == "A": code = self._convert("TO_A") elif which == "B": @@ -158,11 +179,21 @@ def _new_charset(self, which): self._charset = which return [code] - def _maybe_switch_charset(self, pos): + # to be redefined in subclass if required + def _is_char_fnc1_char(self, char): + """Whether a character is the FNC1 character. + + May be redefined by subclasses if required. FNC1 char is defined in GS1-128 + specification and it is defined just the same for all encodings therefore this + sign should be treated in a special way. + """ + return False + + def _maybe_switch_charset(self, pos: int) -> list[int]: char = self.code[pos] next_ = self.code[pos : pos + 10] - def look_next(): + def look_next() -> bool: digits = 0 for c in next_: if c.isdigit(): @@ -171,71 +202,97 @@ def look_next(): break return digits > 3 - codes = [] + codes: list[int] = [] if self._charset == "C" and not char.isdigit(): + if self._is_char_fnc1_char(char) and not self._digit_buffer: + return codes if char in code128.B: codes = self._new_charset("B") elif char in code128.A: codes = self._new_charset("A") - if len(self._buffer) == 1: - codes.append(self._convert(self._buffer[0])) - self._buffer = "" + assert self._charset != "C" + if len(self._digit_buffer) == 1: + # Flush the remaining single digit from the buffer + codes.append(self._convert(self._digit_buffer[0])) + self._digit_buffer = "" elif self._charset == "B": if look_next(): codes = self._new_charset("C") - elif char not in code128.B: - if char in code128.A: - codes = self._new_charset("A") + elif char not in code128.B and char in code128.A: + codes = self._new_charset("A") elif self._charset == "A": if look_next(): codes = self._new_charset("C") - elif char not in code128.A: - if char in code128.B: - codes = self._new_charset("B") + elif char not in code128.A and char in code128.B: + codes = self._new_charset("B") return codes - def _convert(self, char): + def _convert(self, char: str) -> int: + """Convert a character to a code number for the current charset. + + NOTE: encoding digits with charset C requires buffering and is not supported + here. Use _convert_or_buffer instead. + """ if self._charset == "A": return code128.A[char] - elif self._charset == "B": + if self._charset == "B": return code128.B[char] - elif self._charset == "C": - if char in code128.C: + if self._charset == "C": + if char in ["TO_A", "TO_B"]: return code128.C[char] - elif char.isdigit(): - self._buffer += char - if len(self._buffer) == 2: - value = int(self._buffer) - self._buffer = "" - return value - - def _try_to_optimize(self, encoded): + raise RuntimeError("Use _convert_or_buffer for charset C.") + raise RuntimeError( + f"Character {char} could not be converted in charset {self._charset}." + ) + + def _convert_or_buffer(self, char: str) -> int | None: + """Convert a character to a code number for the current charset. + + If charset C is active then digits are encoded in pairs. When the first digit + is encountered, it is buffered and None is returned. + """ + if self._charset != "C": + return self._convert(char) + if char in code128.C: + return code128.C[char] + if char.isdigit(): + self._digit_buffer += char + if len(self._digit_buffer) == 1: + # Wait for the second digit to group in pairs + return None + assert len(self._digit_buffer) == 2 + value = int(self._digit_buffer) + self._digit_buffer = "" + return value + raise RuntimeError(f"Character {char} could not be converted in charset C.") + + def _try_to_optimize(self, encoded: list[int]) -> list[int]: if encoded[1] in code128.TO: encoded[:2] = [code128.TO[encoded[1]]] return encoded - def _calculate_checksum(self, encoded): + def _calculate_checksum(self, encoded: list[int]) -> int: cs = [encoded[0]] for i, code_num in enumerate(encoded[1:], start=1): cs.append(i * code_num) return sum(cs) % 103 - def _build(self): - encoded = [code128.START_CODES[self._charset]] + def _build(self) -> list[int]: + encoded: list[int] = [code128.START_CODES[self._charset]] for i, char in enumerate(self.code): encoded.extend(self._maybe_switch_charset(i)) - code_num = self._convert(char) + code_num = self._convert_or_buffer(char) if code_num is not None: encoded.append(code_num) - # Finally look in the buffer - if len(self._buffer) == 1: + # If we finish in charset C with a single digit remaining in the buffer, + # switch to charset B and flush out the buffer. + if len(self._digit_buffer) == 1: encoded.extend(self._new_charset("B")) - encoded.append(self._convert(self._buffer[0])) - self._buffer = "" - encoded = self._try_to_optimize(encoded) - return encoded + encoded.append(self._convert(self._digit_buffer[0])) + self._digit_buffer = "" + return self._try_to_optimize(encoded) - def build(self): + def build(self) -> list[str]: encoded = self._build() encoded.append(self._calculate_checksum(encoded)) code = "" @@ -251,7 +308,7 @@ def render(self, writer_options=None, text=None): return super().render(options, text) -class Gs1_128(Code128): +class Gs1_128(Code128): # 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 @@ -263,13 +320,16 @@ class Gs1_128(Code128): FNC1_CHAR = "\xf1" - def __init__(self, code, writer=None): + def __init__(self, code, writer=None) -> None: code = self.FNC1_CHAR + code super().__init__(code, writer) def get_fullcode(self): return super().get_fullcode()[1:] + def _is_char_fnc1_char(self, char): + return char == self.FNC1_CHAR + # For pre 0.8 compatibility PZN = PZN7 diff --git a/barcode/ean.py b/barcode/ean.py index 1ffac4d..e001ccc 100755 --- a/barcode/ean.py +++ b/barcode/ean.py @@ -2,9 +2,11 @@ :Provided barcodes: EAN-14, EAN-13, EAN-8, JAN """ + +from __future__ import annotations + __docformat__ = "restructuredtext en" -from functools import reduce from barcode.base import Barcode from barcode.charsets import ean as _ean @@ -30,38 +32,41 @@ class EuropeanArticleNumber13(Barcode): """Initializes EAN13 object. - :parameters: - ean : String - The ean number as string. - writer : barcode.writer Instance - The writer to render the barcode (default: SVGWriter). + :param ean: The ean number as string. If the value is too long, it is trimmed. + :param writer: The writer to render the barcode (default: SVGWriter). + :param no_checksum: Don't calculate the checksum. Use the provided input instead. """ name = "EAN-13" digits = 12 - def __init__(self, ean, writer=None, no_checksum=False, guardbar=False): - ean = ean[: self.digits] - if not ean.isdigit(): - raise IllegalCharacterError("EAN code can only contain numbers.") - if len(ean) != self.digits: + def __init__( + self, + ean: str, + writer=None, + no_checksum: bool = False, + guardbar: bool = False, + ) -> None: + if not ean[: self.digits].isdigit(): + raise IllegalCharacterError(f"EAN code can only contain numbers {ean}.") + + if len(ean) < self.digits: raise NumberOfDigitsError( - "EAN must have {} digits, not {}.".format( - self.digits, - len(ean), - ) + f"EAN must have {self.digits} digits, received {len(ean)}." ) - self.ean = ean - # If no checksum + + base = ean[: self.digits] if no_checksum: - # Add a thirteen char if given in parameter, - # otherwise pad with zero - self.ean = "{}{}".format( - ean, ean[self.digits] if len(ean) > self.digits else 0 - ) + # Use the thirteenth digit if given in parameter, otherwise pad with zero + if len(ean) > self.digits and ean[self.digits].isdigit(): + last = int(ean[self.digits]) + else: + last = 0 else: - self.ean = f"{ean}{self.calculate_checksum()}" + last = self.calculate_checksum(base) + + self.ean = f"{base}{last}" self.guardbar = guardbar if guardbar: @@ -72,33 +77,32 @@ def __init__(self, ean, writer=None, no_checksum=False, guardbar=False): self.MIDDLE = _ean.MIDDLE self.writer = writer or self.default_writer() - def __str__(self): + def __str__(self) -> str: return self.ean - def get_fullcode(self): + def get_fullcode(self) -> str: if self.guardbar: return self.ean[0] + " " + self.ean[1:7] + " " + self.ean[7:] + " >" return self.ean - def calculate_checksum(self): - """Calculates the checksum for EAN13-Code. + def calculate_checksum(self, value: str | None = None) -> int: + """Calculates and returns the checksum for EAN13-Code. - :returns: The checksum for `self.ean`. - :rtype: Integer + Calculates the checksum for the supplied `value` (if any) or for this barcode's + internal ``self.ean`` property. """ - def sum_(x, y): - return int(x) + int(y) + ean_without_checksum = value or self.ean[: self.digits] - evensum = reduce(sum_, self.ean[-2::-2]) - oddsum = reduce(sum_, self.ean[-1::-2]) + evensum = sum(int(x) for x in ean_without_checksum[-2::-2]) + oddsum = sum(int(x) for x in ean_without_checksum[-1::-2]) return (10 - ((evensum + oddsum * 3) % 10)) % 10 - def build(self): + def build(self) -> list[str]: """Builds the barcode pattern from `self.ean`. :returns: The pattern as string - :rtype: String + :rtype: List containing the string as a single element """ code = self.EDGE[:] pattern = _ean.LEFT_PATTERN[int(self.ean[0])] @@ -110,28 +114,30 @@ def build(self): code += self.EDGE return [code] - def to_ascii(self): + def to_ascii(self) -> str: """Returns an ascii representation of the barcode. :rtype: String """ - code = self.build() - for i, line in enumerate(code): - code[i] = line.replace("G", "|").replace("1", "|").replace("0", " ") - return "\n".join(code) + code_list = self.build() + if not len(code_list) == 1: + raise RuntimeError("Code list must contain a single element.") + code = code_list[0] + return code.replace("G", "|").replace("1", "|").replace("0", " ") - def render(self, writer_options=None, text=None): + def render(self, writer_options: dict | None = None, text: str | None = None): options = {"module_width": SIZES["SC2"]} options.update(writer_options or {}) return super().render(options, text) class EuropeanArticleNumber13WithGuard(EuropeanArticleNumber13): + """A shortcut to EAN-13 with ``guardbar=True``.""" name = "EAN-13 with guards" - def __init__(self, *args, guardbar=True, **kwargs): - return super().__init__(*args, guardbar=guardbar, **kwargs) + def __init__(self, ean, writer=None, no_checksum=False, guardbar=True) -> None: + super().__init__(ean, writer, no_checksum, guardbar) class JapanArticleNumber(EuropeanArticleNumber13): @@ -148,7 +154,7 @@ class JapanArticleNumber(EuropeanArticleNumber13): valid_country_codes = list(range(450, 460)) + list(range(490, 500)) - def __init__(self, jan, *args, **kwargs): + def __init__(self, jan, *args, **kwargs) -> None: if int(jan[:3]) not in self.valid_country_codes: raise WrongCountryCodeError( "Country code isn't between 450-460 or 490-500." @@ -159,22 +165,18 @@ def __init__(self, jan, *args, **kwargs): class EuropeanArticleNumber8(EuropeanArticleNumber13): """Represents an EAN-8 barcode. See EAN13's __init__ for details. - :parameters: - ean : String - The ean number as string. - writer : barcode.writer Instance - The writer to render the barcode (default: SVGWriter). + :param ean: The ean number as string. + :param writer: The writer to render the barcode (default: SVGWriter). """ name = "EAN-8" digits = 7 - def build(self): + def build(self) -> list[str]: """Builds the barcode pattern from `self.ean`. - :returns: The pattern as string - :rtype: String + :returns: A list containing the string as a single element """ code = self.EDGE[:] for number in self.ean[:4]: @@ -192,38 +194,42 @@ def get_fullcode(self): class EuropeanArticleNumber8WithGuard(EuropeanArticleNumber8): + """A shortcut to EAN-8 with ``guardbar=True``.""" name = "EAN-8 with guards" - def __init__(self, *args, guardbar=True, **kwargs): - return super().__init__(*args, guardbar=guardbar, **kwargs) + def __init__( + self, + ean: str, + writer=None, + no_checksum: bool = False, + guardbar: bool = True, + ) -> None: + super().__init__(ean, writer, no_checksum, guardbar) class EuropeanArticleNumber14(EuropeanArticleNumber13): """Represents an EAN-14 barcode. See EAN13's __init__ for details. - :parameters: - ean : String - The ean number as string. - writer : barcode.writer Instance - The writer to render the barcode (default: SVGWriter). + :param ean: The ean number as string. + :param writer: The writer to render the barcode (default: SVGWriter). + :param no_checksum: Don't calculate the checksum. Use the provided input instead. """ name = "EAN-14" digits = 13 - def calculate_checksum(self): - """Calculates the checksum for EAN13-Code. + def calculate_checksum(self, value: str | None = None) -> int: + """Calculates and returns the checksum for EAN14-Code. - :returns: The checksum for `self.ean`. - :rtype: Integer + Calculates the checksum for the supplied `value` (if any) or for this barcode's + internal ``self.ean`` property. """ - def sum_(x, y): - return int(x) + int(y) + ean_without_checksum = value or self.ean[: self.digits] - evensum = reduce(sum_, self.ean[::2]) - oddsum = reduce(sum_, self.ean[1::2]) + evensum = sum(int(x) for x in ean_without_checksum[::2]) + oddsum = sum(int(x) for x in ean_without_checksum[1::2]) return (10 - (((evensum * 3) + oddsum) % 10)) % 10 diff --git a/barcode/errors.py b/barcode/errors.py index 7fb448f..4725187 100755 --- a/barcode/errors.py +++ b/barcode/errors.py @@ -1,12 +1,15 @@ """barcode.errors""" + +from __future__ import annotations + __docformat__ = "restructuredtext en" class BarcodeError(Exception): - def __init__(self, msg): + def __init__(self, msg) -> None: self.msg = msg - def __str__(self): + def __str__(self) -> str: return self.msg diff --git a/barcode/isxn.py b/barcode/isxn.py index 3588179..e74ea58 100755 --- a/barcode/isxn.py +++ b/barcode/isxn.py @@ -21,6 +21,9 @@ '0132354187' """ + +from __future__ import annotations + from barcode.ean import EuropeanArticleNumber13 from barcode.errors import BarcodeError from barcode.errors import WrongCountryCodeError @@ -40,15 +43,14 @@ class InternationalStandardBookNumber13(EuropeanArticleNumber13): name = "ISBN-13" - def __init__(self, isbn, writer=None): + def __init__(self, isbn, writer=None, no_checksum=False, guardbar=False) -> None: isbn = isbn.replace("-", "") self.isbn13 = isbn if isbn[:3] not in ("978", "979"): raise WrongCountryCodeError("ISBN must start with 978 or 979.") - if isbn[:3] == "979": - if isbn[3:5] not in ("10", "11"): - raise BarcodeError("ISBN must start with 97910 or 97911.") - super().__init__(isbn, writer) + if isbn[:3] == "979" and isbn[3:4] not in ("1", "8"): + raise BarcodeError("ISBN must start with 97910 or 97911.") + super().__init__(isbn, writer, no_checksum, guardbar) class InternationalStandardBookNumber10(InternationalStandardBookNumber13): @@ -66,21 +68,21 @@ class InternationalStandardBookNumber10(InternationalStandardBookNumber13): digits = 9 - def __init__(self, isbn, writer=None): + def __init__(self, isbn, writer=None) -> None: isbn = isbn.replace("-", "") isbn = isbn[: self.digits] + super().__init__("978" + isbn, writer) self.isbn10 = isbn self.isbn10 = f"{isbn}{self._calculate_checksum()}" - super().__init__("978" + isbn, writer) def _calculate_checksum(self): tmp = sum(x * int(y) for x, y in enumerate(self.isbn10[:9], start=1)) % 11 if tmp == 10: return "X" - else: - return tmp - def __str__(self): + return tmp + + def __str__(self) -> str: return self.isbn10 @@ -99,7 +101,7 @@ class InternationalStandardSerialNumber(EuropeanArticleNumber13): digits = 7 - def __init__(self, issn, writer=None): + def __init__(self, issn, writer=None) -> None: issn = issn.replace("-", "") issn = issn[: self.digits] self.issn = issn @@ -114,13 +116,13 @@ def _calculate_checksum(self): ) if tmp == 10: return "X" - else: - return tmp + + return tmp def make_ean(self): return f"977{self.issn[:7]}00{self._calculate_checksum()}" - def __str__(self): + def __str__(self) -> str: return self.issn diff --git a/barcode/itf.py b/barcode/itf.py index b79b4bd..f40d9cb 100644 --- a/barcode/itf.py +++ b/barcode/itf.py @@ -2,6 +2,9 @@ :Provided barcodes: Interleaved 2 of 5 """ + +from __future__ import annotations + __docformat__ = "restructuredtext en" from barcode.base import Barcode @@ -29,7 +32,7 @@ class ITF(Barcode): name = "ITF" - def __init__(self, code, writer=None, narrow=2, wide=5): + def __init__(self, code, writer=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 @@ -40,13 +43,13 @@ def __init__(self, code, writer=None, narrow=2, wide=5): self.narrow = narrow self.wide = wide - def __str__(self): + def __str__(self) -> str: return self.code def get_fullcode(self): return self.code - def build(self): + def build(self) -> list[str]: data = itf.START for i in range(0, len(self.code), 2): bars_digit = int(self.code[i]) diff --git a/barcode/pybarcode.py b/barcode/pybarcode.py index b21ca5d..35d69b0 100644 --- a/barcode/pybarcode.py +++ b/barcode/pybarcode.py @@ -1,15 +1,18 @@ +from __future__ import annotations + import os from argparse import ArgumentParser import barcode from barcode.version import version +from barcode.writer import BaseWriter from barcode.writer import ImageWriter from barcode.writer import SVGWriter IMG_FORMATS = ("BMP", "GIF", "JPEG", "MSP", "PCX", "PNG", "TIFF", "XBM") -def list_types(args, parser=None): +def list_types(args, parser=None) -> None: print("\npython-barcode available barcode formats:") print(", ".join(barcode.PROVIDED_BARCODES)) print("\n") @@ -22,24 +25,19 @@ def list_types(args, parser=None): print("\n") -def create_barcode(args, parser): +def create_barcode(args, parser) -> None: args.type = args.type.upper() if args.type != "SVG" and args.type not in IMG_FORMATS: - parser.error( - "Unknown type {type}. Try list action for available types.".format( - type=args.type - ) - ) + parser.error(f"Unknown type {args.type}. Try list action for available types.") args.barcode = args.barcode.lower() if args.barcode not in barcode.PROVIDED_BARCODES: parser.error( - "Unknown barcode {bc}. Try list action for available barcodes.".format( - bc=args.barcode - ) + f"Unknown barcode {args.barcode}. Try list action for available barcodes." ) if args.type != "SVG": + assert ImageWriter is not None opts = {"format": args.type} - writer = ImageWriter() + writer: BaseWriter = ImageWriter() else: opts = {"compress": args.compress} writer = SVGWriter() @@ -48,7 +46,7 @@ def create_barcode(args, parser): print(f"New barcode saved as {name}.") -def main(): +def main() -> None: msg = [] if ImageWriter is None: msg.append("Image output disabled (Pillow not found), --type option disabled.") diff --git a/barcode/upc.py b/barcode/upc.py index bf7e88d..060f19f 100755 --- a/barcode/upc.py +++ b/barcode/upc.py @@ -2,6 +2,9 @@ :Provided barcodes: UPC-A """ + +from __future__ import annotations + __docformat__ = "restructuredtext en" from functools import reduce @@ -22,7 +25,7 @@ class UniversalProductCodeA(Barcode): digits = 11 - def __init__(self, upc, writer=None, make_ean=False): + def __init__(self, upc, writer=None, make_ean=False) -> None: """Initializes new UPC-A barcode. :param str upc: The upc number as string. @@ -44,17 +47,17 @@ def __init__(self, upc, writer=None, make_ean=False): self.upc = f"{upc}{self.calculate_checksum()}" self.writer = writer or self.default_writer() - def __str__(self): + def __str__(self) -> str: if self.ean: return "0" + self.upc - else: - return self.upc + + return self.upc def get_fullcode(self): if self.ean: return "0" + self.upc - else: - return self.upc + + return self.upc def calculate_checksum(self): """Calculates the checksum for UPCA/UPC codes @@ -72,14 +75,14 @@ def sum_(x, y): check = (evensum + oddsum * 3) % 10 if check == 0: return 0 - else: - return 10 - check - def build(self): + return 10 - check + + def build(self) -> list[str]: """Builds the barcode pattern from 'self.upc' :return: The pattern as string - :rtype: str + :rtype: List containing the string as a single element """ code = _upc.EDGE[:] @@ -95,16 +98,17 @@ def build(self): return [code] - def to_ascii(self): + def to_ascii(self) -> str: """Returns an ascii representation of the barcode. :rtype: str """ - code = self.build() - for i, line in enumerate(code): - code[i] = line.replace("1", "|").replace("0", "_") - return "\n".join(code) + code_list = self.build() + if len(code_list) != 1: + raise RuntimeError("Code list must contain a single element.") + code = code_list[0] + return code.replace("1", "|").replace("0", "_") def render(self, writer_options=None, text=None): options = {"module_width": 0.33} diff --git a/barcode/writer.py b/barcode/writer.py index 042d847..1bed3fc 100755 --- a/barcode/writer.py +++ b/barcode/writer.py @@ -1,48 +1,73 @@ +from __future__ import annotations + import gzip import os -import xml.dom +import xml.dom.minidom +from typing import TYPE_CHECKING from typing import BinaryIO +from typing import Callable +from typing import TypedDict from barcode.version import version +if TYPE_CHECKING: + from collections.abc import Generator + from typing import Literal + + from PIL.Image import Image as T_Image + from PIL.ImageDraw import ImageDraw as T_ImageDraw + + class InternalText(TypedDict): + start: list + end: list + xpos: list + was_guard: bool + + class Callbacks(TypedDict): + initialize: Callable | None + paint_module: Callable + paint_text: Callable | None + finish: Callable + + try: - import Image - import ImageDraw - import ImageFont + from PIL import Image + from PIL import ImageDraw + from PIL import ImageFont except ImportError: - try: - from PIL import Image # lint:ok - from PIL import ImageDraw - from PIL import ImageFont - except ImportError: - import logging + import logging - log = logging.getLogger("pyBarcode") - log.info("Pillow not found. Image output disabled") - Image = ImageDraw = ImageFont = None # lint:ok + log = logging.getLogger("pyBarcode") + log.info("Pillow not found. Image output disabled") + Image = ImageDraw = ImageFont = None # type: ignore[assignment] -def mm2px(mm, dpi=300): +def mm2px(mm: float, dpi: int) -> float: return (mm * dpi) / 25.4 -def pt2mm(pt): +def pt2mm(pt: float) -> float: return pt * 0.352777778 -def _set_attributes(element, **attributes): +def _set_attributes( + element: xml.dom.minidom.Element, + **attributes: str, +) -> None: for key, value in attributes.items(): element.setAttribute(key, value) -def create_svg_object(with_doctype=False): - imp = xml.dom.getDOMImplementation() +def create_svg_object(with_doctype: bool = False) -> xml.dom.minidom.Document: + imp = xml.dom.minidom.getDOMImplementation() + assert imp is not None doctype = imp.createDocumentType( "svg", "-//W3C//DTD SVG 1.1//EN", "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd", ) document = imp.createDocument(None, "svg", doctype if with_doctype else None) + assert document.documentElement is not None _set_attributes( document.documentElement, version="1.1", xmlns="http://www.w3.org/2000/svg" ) @@ -57,31 +82,46 @@ def create_svg_object(with_doctype=False): class BaseWriter: """Baseclass for all writers. - Initializes the basic writer options. Childclasses can add more - attributes and can set them directly or using - `self.set_options(option=value)`. - - :parameters: - initialize : Function - Callback for initializing the inheriting writer. - Is called: `callback_initialize(raw_code)` - paint_module : Function - Callback for painting one barcode module. - Is called: `callback_paint_module(xpos, ypos, width, color)` - paint_text : Function - Callback for painting the text under the barcode. - Is called: `callback_paint_text(xpos, ypos)` using `self.text` - as text. - finish : Function - Callback for doing something with the completely rendered - output. - Is called: `return callback_finish()` and must return the - rendered output. + Initializes the basic writer options. Child classes can add more attributes and can + set them directly or using ``self.set_options(option=value)``. + + :param initialize: Callback for initializing the inheriting writer. + Is called: ``callback_initialize(raw_code)`` + :param paint_module: + Callback for painting one barcode module. + Is called: ``callback_paint_module(xpos, ypos, width, color)`` + :param paint_text: Callback for painting the text under the barcode. + Is called: ``callback_paint_text(xpos, ypos)`` using `self.text` + as text. + :param finish: Callback for doing something with the completely rendered + output. Is called: ``return callback_finish()`` and must return the + rendered output. """ + _callbacks: Callbacks + module_width: float + module_height: float + font_path: str + font_size: float + quiet_zone: float + background: str | int + foreground: str | int + text: str + human: str + text_distance: float + text_line_distance: float + center_text: bool + guard_height_factor: float + margin_top: float + margin_bottom: float + def __init__( - self, initialize=None, paint_module=None, paint_text=None, finish=None - ): + self, + initialize: Callable | None, + paint_module: Callable, + paint_text: Callable | None, + finish: Callable, + ) -> None: self._callbacks = { "initialize": initialize, "paint_module": paint_module, @@ -101,21 +141,21 @@ def __init__( self.text_line_distance = 1 self.center_text = True self.guard_height_factor = 1.1 + self.margin_top = 1 + self.margin_bottom = 1 - def calculate_size(self, modules_per_line, number_of_lines): + def calculate_size(self, modules_per_line: int, number_of_lines: int) -> tuple: """Calculates the size of the barcode in pixel. - :parameters: - modules_per_line : Integer - Number of modules in one line. - number_of_lines : Integer - Number of lines of the barcode. + :param modules_per_line: Number of modules in one line. + :param number_of_lines: Number of lines of the barcode. :returns: Width and height of the barcode in pixel. - :rtype: Tuple """ width = 2 * self.quiet_zone + modules_per_line * self.module_width - height = 2.0 + self.module_height * number_of_lines + height = ( + self.margin_bottom + self.margin_top + self.module_height * number_of_lines + ) number_of_text_lines = len(self.text.splitlines()) if self.font_size and self.text: height += ( @@ -124,49 +164,41 @@ def calculate_size(self, modules_per_line, number_of_lines): height += self.text_line_distance * (number_of_text_lines - 1) return width, height - def save(self, filename, output): + def save(self, filename: str, output) -> str: """Saves the rendered output to `filename`. - :parameters: - filename : String - Filename without extension. - output : String - The rendered output. + :param filename: Filename without extension. + :param output: The rendered output. :returns: The full filename with extension. - :rtype: String """ raise NotImplementedError - def register_callback(self, action, callback): - """Register one of the three callbacks if not given at instance - creation. + def register_callback( + self, + action: Literal["initialize", "paint_module", "paint_text", "finish"], + callback: Callable, + ) -> None: + """Register one of the three callbacks if not given at instance creation. - :parameters: - action : String - One of 'initialize', 'paint_module', 'paint_text', 'finish'. - callback : Function - The callback function for the given action. + :param action: One of 'initialize', 'paint_module', 'paint_text', 'finish'. + :param callback: The callback function for the given action. """ self._callbacks[action] = callback - def set_options(self, options): + def set_options(self, options: dict) -> None: """Sets the given options as instance attributes (only if they are known). - :parameters: - options : Dict - All known instance attributes and more if the childclass - has defined them before this call. - - :rtype: None + :param options: All known instance attributes and more if the child class + has defined them before this call. """ for key, val in options.items(): key = key.lstrip("_") if hasattr(self, key): setattr(self, key, val) - def packed(self, line): + def packed(self, line: str) -> Generator[tuple[int, float], str, None]: """ Pack line to list give better gfx result, otherwise in can result in aliasing gaps @@ -174,14 +206,11 @@ def packed(self, line): This method will yield a sequence of pairs (width, height_factor). - :parameters: - line: String - A string matching the writer spec - (only contain 0 or 1 or G). + :param line: A string matching the writer spec (only contain 0 or 1 or G). """ line += " " c = 1 - for i in range(0, len(line) - 1): + for i in range(len(line) - 1): if line[i] == line[i + 1]: c += 1 else: @@ -193,75 +222,66 @@ def packed(self, line): yield (-c, self.guard_height_factor) c = 1 - def render(self, code): + def render(self, code: list[str]): """Renders the barcode to whatever the inheriting writer provides, using the registered callbacks. :parameters: code : List - List of strings matching the writer spec + List consisting of a single string matching the writer spec (only contain 0 or 1 or G). """ if self._callbacks["initialize"] is not None: self._callbacks["initialize"](code) - ypos = 1.0 + ypos = self.margin_top base_height = self.module_height - for cc, line in enumerate(code): - # Left quiet zone is x startposition - xpos = self.quiet_zone - bxs = xpos # x start of barcode - text = { - "start": [], # The x start of a guard - "end": [], # The x end of a guard - "xpos": [], # The x position where to write a text block - # Flag that indicates if the previous mod was part of an guard block: - "was_guard": False, - } - for mod, height_factor in self.packed(line): - if mod < 1: - color = self.background - else: - color = self.foreground - - if text["was_guard"] and height_factor == 1: - # The current guard ended, store its x position - text["end"].append(xpos) - text["was_guard"] = False - elif not text["was_guard"] and height_factor != 1: - # A guard started, store its x position - text["start"].append(xpos) - text["was_guard"] = True - - self.module_height = base_height * height_factor - # remove painting for background colored tiles? - self._callbacks["paint_module"]( - xpos, ypos, self.module_width * abs(mod), color - ) - xpos += self.module_width * abs(mod) + if len(code) != 1: + raise NotImplementedError("Only one line of code is supported") + line = code[0] + # Left quiet zone is x startposition + xpos = self.quiet_zone + bxs = xpos # x start of barcode + text: InternalText = { + "start": [], # The x start of a guard + "end": [], # The x end of a guard + "xpos": [], # The x position where to write a text block + # Flag that indicates if the previous mod was part of an guard block: + "was_guard": False, + } + for mod, height_factor in self.packed(line): + if mod < 1: + color = self.background else: - if height_factor != 1: + color = self.foreground + + if text["was_guard"] and height_factor == 1: + # The current guard ended, store its x position text["end"].append(xpos) - self.module_height = base_height - - bxe = xpos - # Add right quiet zone to every line, except last line, - # quiet zone already provided with background, - # should it be removed completely? - if (cc + 1) != len(code): - self._callbacks["paint_module"]( - xpos, ypos, self.quiet_zone, self.background - ) - ypos += self.module_height + text["was_guard"] = False + elif not text["was_guard"] and height_factor != 1: + # A guard started, store its x position + text["start"].append(xpos) + text["was_guard"] = True + + self.module_height = base_height * height_factor + # remove painting for background colored tiles? + self._callbacks["paint_module"]( + xpos, ypos, self.module_width * abs(mod), color + ) + xpos += self.module_width * abs(mod) + else: + if height_factor != 1: + text["end"].append(xpos) + self.module_height = base_height + + bxe = xpos + ypos += self.module_height if self.text and self._callbacks["paint_text"] is not None: if not text["start"]: # If we don't have any start value, print the entire ean ypos += self.text_distance - if self.center_text: - # better center position for text - xpos = bxs + ((bxe - bxs) / 2.0) - else: - xpos = bxs + xpos = bxs + (bxe - bxs) / 2.0 if self.center_text else bxs self._callbacks["paint_text"](xpos, ypos) else: # Else, divide the ean into blocks and print each block @@ -271,39 +291,47 @@ def render(self, code): # Calculates the position of the text by getting the difference # between a guard end and the next start text["start"].pop(0) - for (s, e) in zip(text["start"], text["end"]): + for s, e in zip(text["start"], text["end"]): text["xpos"].append(e + (s - e) / 2) # The last text block is always put after the last guard end text["xpos"].append(text["end"][-1] + 4 * self.module_width) - # Split the ean into its blocks - self.text = self.text.split(" ") - ypos += pt2mm(self.font_size) - blocks = self.text - for (text_, xpos) in zip(blocks, text["xpos"]): + # Split the ean into its blocks + blocks = self.text.split(" ") + for text_, xpos in zip(blocks, text["xpos"]): self.text = text_ self._callbacks["paint_text"](xpos, ypos) return self._callbacks["finish"]() + def write(self, content, fp: BinaryIO) -> None: + raise NotImplementedError + class SVGWriter(BaseWriter): - def __init__(self): - BaseWriter.__init__( - self, self._init, self._create_module, self._create_text, self._finish + def __init__(self) -> None: + super().__init__( + self._init, + self._create_module, + self._create_text, + self._finish, ) - self.compress = False - self.with_doctype = True - self._document = None - self._root = None - self._group = None - - def _init(self, code): - width, height = self.calculate_size(len(code[0]), len(code)) + self.compress: bool = False + self.with_doctype: bool = True + self._document: xml.dom.minidom.Document + self._root: xml.dom.minidom.Element + self._group: xml.dom.minidom.Element + + def _init(self, code: list[str]): + if len(code) != 1: + raise NotImplementedError("Only one line of code is supported") + line = code[0] + width, height = self.calculate_size(len(line), 1) self._document = create_svg_object(self.with_doctype) + assert self._document.documentElement is not None self._root = self._document.documentElement attributes = { "width": SIZE.format(width), @@ -318,14 +346,15 @@ def _init(self, code): attributes = {"id": "barcode_group"} _set_attributes(group, **attributes) self._group = self._root.appendChild(group) - background = self._document.createElement("rect") - attributes = { - "width": "100%", - "height": "100%", - "style": f"fill:{self.background}", - } - _set_attributes(background, **attributes) - self._group.appendChild(background) + if self.background is not None: + background = self._document.createElement("rect") + attributes = { + "width": "100%", + "height": "100%", + "style": f"fill:{self.background}", + } + _set_attributes(background, **attributes) + self._group.appendChild(background) def _create_module(self, xpos, ypos, width, color): # Background rect has been provided already, so skipping "spaces" @@ -344,18 +373,15 @@ def _create_module(self, xpos, ypos, width, color): def _create_text(self, xpos, ypos): # check option to override self.text with self.human (barcode as # human readable data, can be used to print own formats) - if self.human != "": - barcodetext = self.human - else: - barcodetext = self.text + barcodetext = self.human if self.human != "" else self.text for subtext in barcodetext.split("\n"): element = self._document.createElement("text") attributes = { "x": SIZE.format(xpos), "y": SIZE.format(ypos), - "style": "fill:{};font-size:{}pt;text-anchor:middle;".format( - self.foreground, - self.font_size, + "style": ( + f"fill:{self.foreground};" + f"font-size:{self.font_size}pt;text-anchor:middle;" ), } _set_attributes(element, **attributes) @@ -364,27 +390,27 @@ def _create_text(self, xpos, ypos): self._group.appendChild(element) ypos += pt2mm(self.font_size) + self.text_line_distance - def _finish(self): + def _finish(self) -> bytes: if self.compress: return self._document.toxml(encoding="UTF-8") - else: - return self._document.toprettyxml( - indent=4 * " ", newl=os.linesep, encoding="UTF-8" - ) - def save(self, filename, output): + return self._document.toprettyxml( + indent=4 * " ", newl=os.linesep, encoding="UTF-8" + ) + + def save(self, filename: str, output) -> str: if self.compress: _filename = f"{filename}.svgz" - f = gzip.open(_filename, "wb") - f.write(output) - f.close() + with gzip.open(_filename, "wb") as f: + f.write(output) + f.close() else: _filename = f"{filename}.svg" with open(_filename, "wb") as f: f.write(output) return _filename - def write(self, content, fp: BinaryIO): + def write(self, content, fp: BinaryIO) -> None: """Write `content` into a file-like object. Content should be a barcode rendered by this writer. @@ -393,15 +419,20 @@ def write(self, content, fp: BinaryIO): if Image is None: - ImageWriter = None + ImageWriter: type | None = None else: - class ImageWriter(BaseWriter): # type: ignore + class ImageWriter(BaseWriter): # type: ignore[no-redef] format: str mode: str dpi: int - def __init__(self, format="PNG", mode="RGB"): + def __init__( + self, + format: str = "PNG", + mode: str = "RGB", + dpi: int = 300, + ) -> None: """Initialise a new write instance. :params format: The file format for the generated image. This parameter can @@ -409,53 +440,69 @@ def __init__(self, format="PNG", mode="RGB"): :params mode: The colour-mode for the generated image. Set this to RGBA if you wish to use colours with transparency. """ - BaseWriter.__init__( - self, self._init, self._paint_module, self._paint_text, self._finish + super().__init__( + self._init, + self._paint_module, + self._paint_text, + self._finish, ) self.format = format self.mode = mode - self.dpi = 300 - self._image = None - self._draw = None - - def _init(self, code): - width, height = self.calculate_size(len(code[0]), len(code)) + self.dpi = dpi + self._image: T_Image + self._draw: T_ImageDraw + + def _init(self, code: list[str]) -> None: + if ImageDraw is None: + raise RuntimeError("Pillow not found. Cannot create image.") + if len(code) != 1: + raise NotImplementedError("Only one line of code is supported") + line = code[0] + width, height = self.calculate_size(len(line), 1) size = (int(mm2px(width, self.dpi)), int(mm2px(height, self.dpi))) self._image = Image.new(self.mode, size, self.background) self._draw = ImageDraw.Draw(self._image) - def _paint_module(self, xpos, ypos, width, color): + def _paint_module(self, xpos: float, ypos: float, width: float, color): size = [ (mm2px(xpos, self.dpi), mm2px(ypos, self.dpi)), ( - mm2px(xpos + width, self.dpi), + mm2px(xpos + width, self.dpi) - 1, mm2px(ypos + self.module_height, self.dpi), ), ] self._draw.rectangle(size, outline=color, fill=color) def _paint_text(self, xpos, ypos): + assert ImageFont is not None + + # check option to override self.text with self.human (barcode as + # human readable data, can be used to print own formats) + barcodetext = self.human if self.human != "" else self.text + font_size = int(mm2px(pt2mm(self.font_size), self.dpi)) + if font_size <= 0: + return font = ImageFont.truetype(self.font_path, font_size) - for subtext in self.text.split("\n"): - width, height = font.getsize(subtext) - # determine the maximum width of each line + for subtext in barcodetext.split("\n"): pos = ( - mm2px(xpos, self.dpi) - width // 2, - mm2px(ypos, self.dpi) - height, + mm2px(xpos, self.dpi), + mm2px(ypos, self.dpi), + ) + self._draw.text( + pos, subtext, font=font, fill=self.foreground, anchor="md" ) - self._draw.text(pos, subtext, font=font, fill=self.foreground) ypos += pt2mm(self.font_size) / 2 + self.text_line_distance - def _finish(self): + def _finish(self) -> T_Image: return self._image - def save(self, filename, output): + def save(self, filename: str, output) -> str: filename = f"{filename}.{self.format.lower()}" output.save(filename, self.format.upper()) return filename - def write(self, content, fp: BinaryIO): + def write(self, content, 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 e59a4a7..6309ee6 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,8 +1,48 @@ Changelog --------- +v0.16.2 +~~~~~~~ +* Add support for Python 3.13. + +v0.16.1 +~~~~~~~ +* Switch from ``setup.py`` to ``pyproject.toml``. Only affects how installation + from source is performed, and has no runtime impact. + +v0.16.0 +~~~~~~~ + +* **Breaking** Drop support for Python 3.7 and 3.8. +* Make image DPI configurable. +* Fixed inconsistent checksum calculation when calculating the checksum + multiple times for EAN barcodes. +* Update the documentation with some barcodes that were not previously + documented. +* Specifying ``None`` as a background for the ``SVGWriter``, no background is + included resulting in a transparent background. +* Do not paint text if its size would be zero, to avoid an "invalid ppem value" + error with newer versions of Pillow. +* Optimization of code creation, avoiding to many charset switch. + This results in shorter codes; according to GS1 codes should not + be longer than 165 mm (6.5"). (#232) + +v0.15.1 +~~~~~~~ + +* Add missing dependency to release script. + +v0.15.0 +~~~~~~~ + +* **Breaking** Dropped support for Python 3.6 and 3.7. +* Added support for Python 3.11. +* Fixed compatibility with Pillow 10.0. +* Updated ISBN to support newer allocated ranges. +* Improved type hints. + v0.14.0 -~~~~~~~~~~ +~~~~~~~ * **Breaking**: The default dimensions have changed slightly. This is so that the results of generating a PNG and an SVG look more alike. diff --git a/docs/conf.py b/docs/conf.py index 468ef53..81e3338 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,4 @@ +# noqa: INP001 # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full @@ -8,15 +9,14 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +from __future__ import annotations + import barcode # -- Project information ----------------------------------------------------- project = "python-barcode" -copyright = "2020, Hugo Osvaldo Barrera, et al" +copyright = "2017-2023, Hugo Osvaldo Barrera, et al" author = "Hugo Osvaldo Barrera, et al" # The short X.Y version. diff --git a/docs/contents.rst b/docs/contents.rst index 1ddf7c4..de71562 100644 --- a/docs/contents.rst +++ b/docs/contents.rst @@ -36,7 +36,7 @@ Issues and source code are all in `GitHub `_ for further +Donations are welcome. See `here `_ for further details. Licence diff --git a/docs/getting-started.rst b/docs/getting-started.rst index e819933..49cc47a 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -43,7 +43,7 @@ Generating SVG files # Write to a file-like object: rv = BytesIO() - EAN13(str("100000902922"), writer=SVGWriter()).write(rv) + EAN13("100000902922", writer=SVGWriter()).write(rv) # Or to an actual file: with open("somefile.svg", "wb") as f: diff --git a/docs/supported-formats.rst b/docs/supported-formats.rst index 5dc8548..20d8601 100644 --- a/docs/supported-formats.rst +++ b/docs/supported-formats.rst @@ -4,6 +4,15 @@ Supported Formats The following are the supported barcode formats. PRs for other code formats are welcome! +Codabar +------- + +.. inheritance-diagram:: barcode.codabar.CODABAR + :parts: 1 + +.. autoclass:: barcode.codabar.CODABAR + :members: + Code 39 ------- @@ -24,34 +33,48 @@ Code 128 .. autoclass:: barcode.codex.Code128 :members: -PZN7 (aka: PZN) +PZN (aka: PZN7) --------------- -.. inheritance-diagram:: barcode.codex.PZN7 +.. inheritance-diagram:: barcode.codex.PZN :parts: 1 -.. autoclass:: barcode.codex.PZN7 +.. autoclass:: barcode.codex.PZN :members: - EAN-13 ------ -.. inheritance-diagram:: barcode.ean.EuropeanArticleNumber13 +.. inheritance-diagram:: barcode.ean.EuropeanArticleNumber13WithGuard :parts: 1 .. autoclass:: barcode.ean.EuropeanArticleNumber13 :members: +.. autoclass:: barcode.ean.EuropeanArticleNumber13WithGuard + :members: + EAN-8 ----- -.. inheritance-diagram:: barcode.ean.EuropeanArticleNumber8 +.. inheritance-diagram:: barcode.ean.EuropeanArticleNumber8WithGuard :parts: 1 .. autoclass:: barcode.ean.EuropeanArticleNumber8 :members: +.. autoclass:: barcode.ean.EuropeanArticleNumber8WithGuard + :members: + +EAN-14 +------ + +.. inheritance-diagram:: barcode.ean.EuropeanArticleNumber14 + :parts: 1 + +.. autoclass:: barcode.ean.EuropeanArticleNumber14 + :members: + JAN --- @@ -61,8 +84,8 @@ JAN .. autoclass:: barcode.ean.JapanArticleNumber :members: -ISBN-13 -------- +ISBN-13 (aka: GS1, ISBN) +------------------------ .. inheritance-diagram:: barcode.isxn.InternationalStandardBookNumber13 :parts: 1 @@ -97,8 +120,8 @@ UPC-A .. autoclass:: barcode.upc.UniversalProductCodeA :members: -EAN14 ------ +EAN14 (aka: GTIN) +----------------- .. inheritance-diagram:: barcode.ean.EuropeanArticleNumber14 :parts: 1 @@ -116,3 +139,23 @@ GS1-128 .. autoclass:: barcode.codex.Gs1_128 :members: + +ITF +--- + +.. versionadded:: 0.8.0 + +.. inheritance-diagram:: barcode.itf.ITF + :parts: 1 + +.. autoclass:: barcode.itf.ITF + :members: + +UPCA (aka UPC) +-------------- + +.. inheritance-diagram:: barcode.upc.UPCA + :parts: 1 + +.. autoclass:: barcode.upc.UPCA + :members: diff --git a/docs/writers.rst b/docs/writers.rst index f433921..c5399c1 100644 --- a/docs/writers.rst +++ b/docs/writers.rst @@ -32,6 +32,7 @@ be set). :font_size: Font size of the text under the barcode in pt as *integer*. + Font size zero suppresses text. Defaults to **10**. :text_distance: @@ -56,6 +57,14 @@ be set). Some barcode classes change the above defaults to fit in some kind of specification. +BaseWriter +---------- + +Both ``ImageWriter`` and ``SVGWriter`` are subclasses of ``BaseWriter``: + +.. autoclass:: barcode.writer.BaseWriter + :members: + SVGWriter --------- @@ -89,7 +98,7 @@ In addition to the common writer options you can give the following special opti Custom writers -------------- -It's possible to create your own writer by inheriting from `barcode.writer.BaseWriter`. +It's possible to create your own writer by inheriting from ``barcode.writer.BaseWriter``. In your ``__init__`` method call BaseWriter's ``__init__`` and give your callbacks for: @@ -121,5 +130,5 @@ Saving a compressed SVG (SVGZ): >>> filename 'ean13.svgz' -Now you have ean13.svg and the compressed ean13.svgz in your current +Now you have ``ean13.svg`` and the compressed ``ean13.svgz`` in your current working directory. Open it and see the result. diff --git a/publish-release.yaml b/publish-release.yaml new file mode 100644 index 0000000..3555260 --- /dev/null +++ b/publish-release.yaml @@ -0,0 +1,25 @@ +# Run this with: +# hut builds submit -f publish-release.yaml +image: archlinux +packages: + - python-build + - python-setuptools-scm + - python-wheel + - twine +sources: + - https://github.com/WhyNotHugo/python-barcode/ +secrets: + - 0dd39b49-3530-4002-a197-e0ca7fc3fde7 # PyPI token. +tasks: + - check: | + cd python-barcode + git fetch --tags + + # Stop here unless this is a tag. + git describe --exact-match --tags || complete-build + - build: | + cd python-barcode + python -m build --no-isolation + - publish: | + cd python-barcode + twine upload --non-interactive dist/* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..891d6c3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,100 @@ +[build-system] +requires = ["setuptools>=61", "wheel", "setuptools_scm>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-barcode" +description = "Create standard barcodes with Python. No external modules needed. (optional Pillow support included)." +readme = "README.rst" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [ + { name = "Hugo Osvaldo Barrera et al", email = "hugo@whynothugo.nl" } +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dynamic = ["version"] + +[project.optional-dependencies] +images = ["pillow"] + +[project.scripts] +python-barcode = "barcode.pybarcode:main" + +[project.urls] +documentation = "https://python-barcode.readthedocs.io/" +repository = "https://github.com/WhyNotHugo/python-barcode" +issues = "https://github.com/WhyNotHugo/python-barcode/issues" +funding= "https://whynothugo.nl/sponsor/" + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages.find] +exclude = ["tests"] + +[tool.setuptools_scm] +write_to = "barcode/version.py" +version_scheme = "post-release" + +[tool.ruff.lint] +extend-select = [ + "E", + "W", + "I", + "N", + "UP", + "YTT", + "BLE", + "B", + "C4", + "ISC", + "ICN", + "G", + "INP", + "PIE", + "PYI", + "PT", + "Q", + "RSE", + "RET", + "SIM", + "TID", + "TCH", + "INT", + "ERA", + "PGH", + "PLE", + "RUF", +] + +[tool.ruff.lint.isort] +force-single-line = true +required-imports = ["from __future__ import annotations"] + +[tool.coverage.report] +exclude_lines = [ + "if TYPE_CHECKING:", +] + +[tool.pytest.ini_options] +addopts = [ + "-vv", + "--cov=barcode", + "--cov-report=term-missing:skip-covered", + "--no-cov-on-fail", +] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 6571d03..0000000 --- a/setup.cfg +++ /dev/null @@ -1,16 +0,0 @@ -[flake8] -exclude = docs/conf.py -extend-ignore = - E203, # Black-incompatible colon spacing. - W503, # Line jump before binary operator. -max-line-length = 88 - -[isort] -force_single_line=true - -[tool:pytest] -addopts = - -vv - --cov=barcode - --cov-report=term-missing:skip-covered - --no-cov-on-fail diff --git a/setup.py b/setup.py deleted file mode 100755 index 11646a9..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -from pathlib import Path - -from setuptools import find_packages -from setuptools import setup - -setup( - name="python-barcode", - packages=find_packages(exclude=["tests"]), - url="https://github.com/WhyNotHugo/python-barcode", - license="MIT", - author="Hugo Osvaldo Barrera et al", - author_email="hugo@barrera.io", - description=( - "Create standard barcodes with Python. No external modules needed. " - "(optional Pillow support included)." - ), - long_description=Path("README.rst").read_text(), - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Programming Language :: Python", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Topic :: Multimedia :: Graphics", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - entry_points={"console_scripts": ["python-barcode = barcode.pybarcode:main"]}, - use_scm_version={ - "version_scheme": "post-release", - "write_to": "barcode/version.py", - }, - setup_requires=["setuptools_scm"], - extras_require={"images": ["pillow"]}, - include_package_data=True, -) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_builds.py b/tests/test_builds.py index 512c2c5..aac11eb 100755 --- a/tests/test_builds.py +++ b/tests/test_builds.py @@ -1,14 +1,16 @@ +from __future__ import annotations + from barcode import get_barcode -def test_ean8_builds(): +def test_ean8_builds() -> None: ref = "1010100011000110100100110101111010101000100100010011100101001000101" ean = get_barcode("ean8", "40267708") bc = ean.build() assert ref == bc[0] -def test_ean8_builds_with_longer_bars(): +def test_ean8_builds_with_longer_bars() -> None: ref = "G0G01000110001101001001101011110G0G01000100100010011100101001000G0G" ean = get_barcode("ean8", "40267708", options={"guardbar": True}) bc = ean.build() diff --git a/tests/test_checksums.py b/tests/test_checksums.py index f2d4451..b97fef7 100755 --- a/tests/test_checksums.py +++ b/tests/test_checksums.py @@ -1,46 +1,48 @@ +from __future__ import annotations + from barcode import get_barcode -def test_code39_checksum(): +def test_code39_checksum() -> None: code39 = get_barcode("code39", "Code39") - assert "CODE39W" == code39.get_fullcode() + assert code39.get_fullcode() == "CODE39W" -def test_pzn_checksum(): +def test_pzn_checksum() -> None: pzn = get_barcode("pzn", "103940") - assert "PZN-1039406" == pzn.get_fullcode() + assert pzn.get_fullcode() == "PZN-1039406" -def test_ean13_checksum(): +def test_ean13_checksum() -> None: ean = get_barcode("ean13", "400614457735") - assert "4006144577350" == ean.get_fullcode() + assert ean.get_fullcode() == "4006144577350" -def test_ean8_checksum(): +def test_ean8_checksum() -> None: ean = get_barcode("ean8", "6032299") - assert "60322999" == ean.get_fullcode() + assert ean.get_fullcode() == "60322999" -def test_jan_checksum(): +def test_jan_checksum() -> None: jan = get_barcode("jan", "491400614457") - assert "4914006144575" == jan.get_fullcode() + assert jan.get_fullcode() == "4914006144575" -def test_ean14_checksum(): +def test_ean14_checksum() -> None: ean = get_barcode("ean14", "1234567891258") - assert "12345678912589" == ean.get_fullcode() + assert ean.get_fullcode() == "12345678912589" -def test_isbn10_checksum(): +def test_isbn10_checksum() -> None: isbn = get_barcode("isbn10", "376926085") - assert "3769260856" == isbn.isbn10 + assert isbn.isbn10 == "3769260856" # type: ignore[attr-defined] -def test_isbn13_checksum(): +def test_isbn13_checksum() -> None: isbn = get_barcode("isbn13", "978376926085") - assert "9783769260854" == isbn.get_fullcode() + assert isbn.get_fullcode() == "9783769260854" -def test_gs1_128_checksum(): +def test_gs1_128_checksum() -> None: gs1_128 = get_barcode("gs1_128", "00376401856400470087") - assert "00376401856400470087" == gs1_128.get_fullcode() + assert gs1_128.get_fullcode() == "00376401856400470087" diff --git a/tests/test_ean.py b/tests/test_ean.py new file mode 100644 index 0000000..95097de --- /dev/null +++ b/tests/test_ean.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import sys + +import pytest + +from barcode.ean import EAN13 + + +def test_ean_checksum_generated() -> None: + ean = EAN13("842167143322") # input has 12 digits + assert ean.calculate_checksum() == 5 + assert ean.ean == "8421671433225" + + +def test_ean_checksum_zeroed() -> None: + ean = EAN13("842167143322", no_checksum=True) # input has 12 digits + assert ean.calculate_checksum() == 5 + assert ean.ean == "8421671433220" + + +def test_ean_checksum_supplied_and_generated() -> None: + ean = EAN13("8421671433225") # input has 13 digits + assert ean.calculate_checksum() == 5 + assert ean.ean == "8421671433225" + + +def test_ean_checksum_supplied_and_matching() -> None: + ean = EAN13("8421671433225", no_checksum=True) # input has 13 digits + assert ean.calculate_checksum() == 5 + assert ean.ean == "8421671433225" + + +def test_ean_checksum_supplied_and_different() -> None: + ean = EAN13("8421671433229", no_checksum=True) # input has 13 digits + assert ean.calculate_checksum() == 5 + assert ean.ean == "8421671433229" + + +def test_ean_checksum_generated_placeholder() -> None: + ean = EAN13("977114487500X") # input has 13 digits + assert ean.calculate_checksum() == 7 + assert ean.ean == "9771144875007" + + +@pytest.mark.skipif(sys.platform == "win32", reason="no /dev/null") +def test_ean_checksum_supplied_placeholder() -> None: + ean = EAN13("977114487500X", no_checksum=True) # input has 13 digits + assert ean.calculate_checksum() == 7 + assert ean.ean == "9771144875000" + + with open("/dev/null", "wb") as f: + ean.write(f) diff --git a/tests/test_gs1_128.py b/tests/test_gs1_128.py new file mode 100644 index 0000000..3352bf0 --- /dev/null +++ b/tests/test_gs1_128.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import pytest + +from barcode.codex import Gs1_128 + +FNC1_CHAR = "\xf1" +FNC1 = 102 +START_B = 104 +START_C = 105 +FROM_AC_TO_B = 100 +FROM_AB_TO_C = 99 +FROM_BC_TO_A = 101 +CODE_BUILD_TEST = ( + # '(01)01234567891011(11)200622(17)240622(21)88888888' # noqa: ERA001 + ( + "010123456789101111200622172406222188888888", + [ + START_C, + FNC1, + 1, + 1, + 23, + 45, + 67, + 89, + 10, + 11, + 11, + 20, + 6, + 22, + 17, + 24, + 6, + 22, + 21, + 88, + 88, + 88, + 88, + ], + ), + # '(01)01234567891011(11)200622(17)240622(21)888888888' # noqa: ERA001 + ( + "0101234567891011112006221724062221888888888", + [ + START_C, + FNC1, + 1, + 1, + 23, + 45, + 67, + 89, + 10, + 11, + 11, + 20, + 6, + 22, + 17, + 24, + 6, + 22, + 21, + 88, + 88, + 88, + 88, + 100, + 24, + ], + ), + # '(01)01234567891011(11)200622(10)12345(21)1234' # noqa: ERA001 + ( + "0101234567891011112006221012345" + FNC1_CHAR + "211234", + [ + START_C, + FNC1, + 1, + 1, + 23, + 45, + 67, + 89, + 10, + 11, + 11, + 20, + 6, + 22, + 10, + 12, + 34, + FROM_AC_TO_B, + 21, + FNC1, + FROM_AB_TO_C, + 21, + 12, + 34, + ], + ), + # '(01)01234567891011(11)200622(10)1234(21)1234' # noqa: ERA001 + ( + "010123456789101111200622101234" + FNC1_CHAR + "211234", + [ + START_C, + FNC1, + 1, + 1, + 23, + 45, + 67, + 89, + 10, + 11, + 11, + 20, + 6, + 22, + 10, + 12, + 34, + FNC1, + 21, + 12, + 34, + ], + ), + # '(01)01234567891011(11)200622(10)240622(21)888888888' # noqa: ERA001 + ( + "01012345678910111120062210240622" + FNC1_CHAR + "21888888888", + [ + START_C, + FNC1, + 1, + 1, + 23, + 45, + 67, + 89, + 10, + 11, + 11, + 20, + 6, + 22, + 10, + 24, + 6, + 22, + FNC1, + 21, + 88, + 88, + 88, + 88, + 100, + 24, + ], + ), + # '(01)08720299927469(11)240621(17)250621(10)20240621/0001(21)xyz' # noqa: ERA001 + ( + "010872029992746911240621172506211020240621/0001" + FNC1_CHAR + "21xyz", + [ + 105, + 102, + 1, + 8, + 72, + 2, + 99, + 92, + 74, + 69, + 11, + 24, + 6, + 21, + 17, + 25, + 6, + 21, + 10, + 20, + 24, + 6, + 21, + 100, + 15, + 99, + 0, + 1, + 102, + 21, + 100, + 88, + 89, + 90, + ], + ), +) + + +@pytest.mark.parametrize(("target", "answer"), CODE_BUILD_TEST) +def test_code_build(target, answer): + gs1_128 = Gs1_128(target) + assert gs1_128._build() == answer diff --git a/tests/test_init.py b/tests/test_init.py index 33a845d..b78703a 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from io import BytesIO @@ -10,17 +12,17 @@ TESTPATH = os.path.join(PATH, "test_outputs") -def test_generate_without_output(): +def test_generate_without_output() -> None: with pytest.raises(TypeError, match="'output' cannot be None"): barcode.generate("ean13", "123455559121112") -def test_generate_with_file(): +def test_generate_with_file() -> None: with open(os.path.join(TESTPATH, "generate_with_file.jpeg"), "wb") as f: barcode.generate("ean13", "123455559121112", output=f) -def test_generate_with_filepath(): +def test_generate_with_filepath() -> None: # FIXME: extension is added to the filepath even if you include it. rv = barcode.generate( "ean13", @@ -30,13 +32,13 @@ def test_generate_with_filepath(): assert rv == os.path.abspath(os.path.join(TESTPATH, "generate_with_filepath.svg")) -def test_generate_with_file_and_writer(): +def test_generate_with_file_and_writer() -> None: with open(os.path.join(TESTPATH, "generate_with_file_and_writer.jpeg"), "wb") as f: barcode.generate("ean13", "123455559121112", output=f, writer=SVGWriter()) -def test_generate_with_bytesio(): +def test_generate_with_bytesio() -> None: bio = BytesIO() barcode.generate("ean13", "123455559121112", output=bio) # XXX: File is not 100% deterministic; needs to be addressed at some point. - # assert len(bio.getvalue()) == 6127 + # assert len(bio.getvalue()) == 6127 # noqa: ERA001 diff --git a/tests/test_manually.py b/tests/test_manually.py index dc43fe6..802fb03 100755 --- a/tests/test_manually.py +++ b/tests/test_manually.py @@ -1,12 +1,21 @@ """Generates barcodes for visually inspecting the results.""" + +from __future__ import annotations + import codecs import os +from typing import TYPE_CHECKING + +import pytest from barcode import get_barcode from barcode import get_barcode_class from barcode import version from barcode.writer import ImageWriter +if TYPE_CHECKING: + from collections.abc import Iterator + PATH = os.path.dirname(os.path.abspath(__file__)) TESTPATH = os.path.join(PATH, "test_outputs") HTMLFILE = os.path.join(TESTPATH, "index.html") @@ -48,46 +57,51 @@ ) -def test_generating_barcodes(): +@pytest.mark.parametrize(("codename", "code"), TESTCODES) +def test_generating_barcodes( + codename: str, code: str, gather_image_elements_into_html: list[str] +) -> None: os.makedirs(TESTPATH, exist_ok=True) - objects = [] + image_elements = gather_image_elements_into_html - def append(x, y): - objects.append(OBJECTS.format(filename=x, name=y)) + def append(x, y) -> None: + image_elements.append(OBJECTS.format(filename=x, name=y)) - def append_img(x, y): - objects.append(IMAGES.format(filename=x, name=y)) + def append_img(x, y) -> None: + image_elements.append(IMAGES.format(filename=x, name=y)) options = {} - for codename, code in TESTCODES: - bcode = get_barcode(codename, code) + bcode = get_barcode(codename, code) + if codename.startswith("i"): + options["center_text"] = False + else: + options["center_text"] = True + filename = bcode.save(os.path.join(TESTPATH, codename), options=options) + print(f"Code: {bcode.name}, Input: {code}, Output: {bcode.get_fullcode()}") + append(os.path.basename(filename), bcode.name) + if ImageWriter is not None: + bcodec = get_barcode_class(codename) + bcode = bcodec(code, writer=ImageWriter()) + opts = {} if codename.startswith("i"): - options["center_text"] = False + opts["center_text"] = False else: - options["center_text"] = True - filename = bcode.save(os.path.join(TESTPATH, codename), options=options) - print( - "Code: {}, Input: {}, Output: {}".format( - bcode.name, code, bcode.get_fullcode() - ) - ) - append(os.path.basename(filename), bcode.name) - if ImageWriter is not None: - bcodec = get_barcode_class(codename) - bcode = bcodec(code, writer=ImageWriter()) - opts = {} - if codename.startswith("i"): - opts["center_text"] = False - else: - opts["center_text"] = True - filename = bcode.save(os.path.join(TESTPATH, codename), options=opts) - append_img(os.path.basename(filename), bcode.name) - else: - objects.append(NO_PIL) + opts["center_text"] = True + filename = bcode.save(os.path.join(TESTPATH, codename), options=opts) + append_img(os.path.basename(filename), bcode.name) + else: + image_elements.append(NO_PIL) + + +@pytest.fixture(scope="module") +def gather_image_elements_into_html() -> Iterator[list[str]]: + image_elements: list[str] = [] + yield image_elements + # Save htmlfile with all objects with codecs.open(HTMLFILE, "w", encoding="utf-8") as f: - obj = "\n".join(objects) + obj = "\n".join(image_elements) f.write(HTML.format(version=version, body=obj)) print(f"\nNow open {HTMLFILE} in your browser.") diff --git a/tests/test_writers.py b/tests/test_writers.py index 894184d..27454bb 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from io import BytesIO @@ -8,16 +10,20 @@ PATH = os.path.dirname(os.path.abspath(__file__)) TESTPATH = os.path.join(PATH, "test_outputs") -if ImageWriter: +if ImageWriter is not None: + + def test_saving_image_to_byteio() -> None: + assert ImageWriter is not None # workaround for mypy - def test_saving_image_to_byteio(): rv = BytesIO() EAN13(str(100000902922), writer=ImageWriter()).write(rv) with open(f"{TESTPATH}/somefile.jpeg", "wb") as f: EAN13("100000011111", writer=ImageWriter()).write(f) - def test_saving_rgba_image(): + def test_saving_rgba_image() -> None: + assert ImageWriter is not None # workaround for mypy + rv = BytesIO() EAN13(str(100000902922), writer=ImageWriter()).write(rv) @@ -29,7 +35,7 @@ def test_saving_rgba_image(): ) -def test_saving_svg_to_byteio(): +def test_saving_svg_to_byteio() -> None: rv = BytesIO() EAN13(str(100000902922), writer=SVGWriter()).write(rv) @@ -37,7 +43,7 @@ def test_saving_svg_to_byteio(): EAN13("100000011111", writer=SVGWriter()).write(f) -def test_saving_svg_to_byteio_with_guardbar(): +def test_saving_svg_to_byteio_with_guardbar() -> None: rv = BytesIO() EAN13(str(100000902922), writer=SVGWriter(), guardbar=True).write(rv) diff --git a/tox.ini b/tox.ini index bf1cf0c..ba2c87a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = {py36,py37,py38,py39,py310}{,-images} +envlist = py,py-images,mypy,mypy-images skip_missing_interpreters = True [testenv] @@ -10,7 +10,9 @@ deps = commands = pytest --cov barcode usedevelop = True -[flake8] -exclude=.tox,build,.eggs -application-import-names=barcode,tests -import-order-style=smarkets +[testenv:mypy] +deps = + mypy + images: Pillow +commands = mypy . +usedevelop = True