diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ef842181 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.py] +indent_size = 4 +max_line_length = 88 + +[*.{rst,md}] +indent_size = 4 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..879790f1 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +# Ruff format the entire project +5d60da17455607e9c8b7e7fef9940d20027894f2 diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml new file mode 100644 index 00000000..1a81f7b7 --- /dev/null +++ b/.github/workflows/linting.yml @@ -0,0 +1,23 @@ +name: Code Quality Checks + +on: + push: + +jobs: + linting: + name: Code Quality Checks + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Dependencies + run: pip install ruff + + - name: Code Linting + if: always() + run: ruff check qrcode + + - name: Code Formatting + if: always() + run: ruff format --check qrcode diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 00000000..ac181bcd --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,25 @@ +name: Testsuite Run + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + pip install --disable-pip-version-check tox tox-gh-actions + - name: Test with tox + run: tox diff --git a/.gitignore b/.gitignore index e7af1f2f..b8775750 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,12 @@ *.pyc -.tox -dist/ .coverage .coverage.* -htmlcov/ -build/ -qrcode.egg-info/ -.pytest_cache/ .idea/ +.pytest_cache/ +.tox +build/ cov.xml +dist/ +htmlcov/ +poetry.lock +qrcode.egg-info/ diff --git a/CHANGES.rst b/CHANGES.rst index fc5ec62e..55c4df39 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,14 +5,31 @@ Change log 7.5 (unreleased) ================ -- Nothing changed yet. +- Added support for Python 3.11 and 3.12. + +- Drop support for Python <=3.8. + +- Change local development setup to use Poetry_. + +- Testsuite and code quality checks are done through Github Actions. + +- Code quality and formatting utilises ruff_. + +- Removed ``typing_extensions`` as a dependency, as it's no longer required with + having Python 3.9+ as a requirement. + +- Only allow high error correction rate (`qrcode.ERROR_CORRECT_H`) when generating + QR codes with embedded images to ensure content is readable + +.. _Poetry: https://python-poetry.org +.. _ruff: https://astral.sh/ruff 7.4.2 (6 February 2023) ======================= - Allow ``pypng`` factory to allow for saving to a string (like - ``qr.save("some_file.png")``) in addition to file-like objects. + ``qr.save("some_file.png")``) in addition to file-like objects. 7.4.1 (3 February 2023) diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index bc485772..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,7 +0,0 @@ -recursive-include doc *.png -include *.rst -include LICENSE -include signing-key.asc -include tox.ini -include doc/qr.1 -ignore .pypirc \ No newline at end of file diff --git a/PACKAGING.rst b/PACKAGING.rst index 5d00fdb3..db252ca7 100644 --- a/PACKAGING.rst +++ b/PACKAGING.rst @@ -3,8 +3,8 @@ Packaging quick reminder Make sure maintainer dependencies are installed:: - pip install -e .[maintainer,dev] + poetry install Run release command and follow prompt instructions:: - fullrelease + poetry run fullrelease diff --git a/README.rst b/README.rst index 529fa7f6..ec383462 100644 --- a/README.rst +++ b/README.rst @@ -227,7 +227,7 @@ and an embedded image: from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer from qrcode.image.styles.colormasks import RadialGradiantColorMask - qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L) + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_H) qr.add_data('Some data') img_1 = qr.make_image(image_factory=StyledPilImage, module_drawer=RoundedModuleDrawer()) diff --git a/TESTING.rst b/TESTING.rst index 5686832e..2ac28c7e 100644 --- a/TESTING.rst +++ b/TESTING.rst @@ -1,9 +1,9 @@ Testing ======= -First, install tox into your virtualenv:: +First, install dev dependencies:: - pip install --upgrade tox + poetry install --with dev To run all tests, you'll need to install multiple Python interpreters. On a modern Ubuntu distribution you can use ``add-apt-repository @@ -15,5 +15,23 @@ the libraries to build PIL, too. Here's the Ubuntu commands:: sudo apt-get install build-essential python-dev python3-dev sudo apt-get install libjpeg8-dev zlib1g-dev -Finally, just run ``tox``! -If you want, you can test against a specific version like this: ``tox -e py36`` +Here's the OSX Homebrew command: + + brew install libjpeg libtiff little-cms2 openjpeg webp + +Finally, just run ``tox``:: + + poetry run tox + # or + poetry shell + tox + +If you want, you can test against a specific version like this: ``tox -e py312-pil`` + + +Linting +------- + +Run `ruff` to check formatting:: + + ruff format qrcode diff --git a/pyproject.toml b/pyproject.toml index e9a14aa7..fdc40d64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,72 @@ [build-system] -requires = ["setuptools >= 48"] -build-backend = "setuptools.build_meta" +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "qrcode" +version = "8.0.dev0" +packages = [{include = "qrcode"}] +description = "QR Code image generator" +authors = ["Lincoln Loop "] +license = "BSD" +readme = ["README.rst", "CHANGES.rst"] +homepage = "https://github.com/lincolnloop/python-qrcode" +keywords = ["qr", "denso-wave", "IEC18004"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "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 :: Only", + "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +# There is no support for data files yet. +# https://github.com/python-poetry/poetry/issues/9519 +# +# data_files = [ +# { destination = "share/man/man1", from = [ "doc/qr.1" ] }, +# ] + +[tool.poetry.scripts] +qr = 'qrcode.console_scripts:main' + + +[tool.poetry.dependencies] +python = "^3.9" +colorama = {version = "*", platform = "win32"} +pypng = {version = "*", optional = true} +pillow = {version = ">=9.1.0", optional = true} + +[tool.poetry.extras] +pil = ["pillow"] +png = ["pypng"] +all = ["pypng","pillow"] + +[tool.poetry.group.dev.dependencies] +pytest = {version = "*"} +pytest-cov = {version = "*"} +tox = {version = "*"} +ruff = {version = "*"} +pypng = {version = "*"} +pillow = {version = ">=9.1.0"} +docutils = "^0.21.2" +zest-releaser = {extras = ["recommended"], version = "^9.2.0"} + +[tool.zest-releaser] +less-zeros = "yes" +version-levels = 2 +tag-format = "v{version}" +tag-message = "Version {version}" +tag-signing = "yes" +date-format =" %%-d %%B %%Y" +prereleaser.middle = [ + "qrcode.release.update_manpage" +] diff --git a/qrcode/compat/pil.py b/qrcode/compat/pil.py deleted file mode 100644 index de6c6cb7..00000000 --- a/qrcode/compat/pil.py +++ /dev/null @@ -1,12 +0,0 @@ -# Try to import PIL in either of the two ways it can be installed. -Image = None -ImageDraw = None - -try: - from PIL import Image, ImageDraw # type: ignore # noqa: F401 -except ImportError: # pragma: no cover - try: - import Image # type: ignore # noqa: F401 - import ImageDraw # type: ignore # noqa: F401 - except ImportError: - pass diff --git a/qrcode/compat/png.py b/qrcode/compat/png.py new file mode 100644 index 00000000..8d7b9056 --- /dev/null +++ b/qrcode/compat/png.py @@ -0,0 +1,7 @@ +# Try to import png library. +PngWriter = None + +try: + from png import Writer as PngWriter # type: ignore # noqa: F401 +except ImportError: # pragma: no cover + pass diff --git a/qrcode/console_scripts.py b/qrcode/console_scripts.py index 424fe6fd..124265a0 100755 --- a/qrcode/console_scripts.py +++ b/qrcode/console_scripts.py @@ -5,10 +5,12 @@ When stdout is a tty the QR Code is printed to the terminal and when stdout is a pipe to a file an image is written. The default image format is PNG. """ + import optparse import os import sys from typing import Dict, Iterable, NoReturn, Optional, Set, Type +from importlib import metadata import qrcode from qrcode.image.base import BaseImage, DrawerAliases @@ -40,9 +42,8 @@ def main(args=None): if args is None: args = sys.argv[1:] - from pkg_resources import get_distribution - version = get_distribution("qrcode").version + version = metadata.version("qrcode") parser = optparse.OptionParser(usage=(__doc__ or "").strip(), version=version) # Wrap parser.error in a typed NoReturn method for better typing. diff --git a/qrcode/image/pil.py b/qrcode/image/pil.py index 5767148d..57ee13a8 100644 --- a/qrcode/image/pil.py +++ b/qrcode/image/pil.py @@ -1,5 +1,5 @@ import qrcode.image.base -from qrcode.compat.pil import Image, ImageDraw +from PIL import Image, ImageDraw class PilImage(qrcode.image.base.BaseImage): @@ -10,6 +10,9 @@ class PilImage(qrcode.image.base.BaseImage): kind = "PNG" def new_image(self, **kwargs): + if not Image: + raise ImportError("PIL library not found.") + back_color = kwargs.get("back_color", "white") fill_color = kwargs.get("fill_color", "black") diff --git a/qrcode/image/pure.py b/qrcode/image/pure.py index 690ebe0c..5a8b2c5e 100644 --- a/qrcode/image/pure.py +++ b/qrcode/image/pure.py @@ -1,11 +1,10 @@ from itertools import chain -import png +from qrcode.compat.png import PngWriter +from qrcode.image.base import BaseImage -import qrcode.image.base - -class PyPNGImage(qrcode.image.base.BaseImage): +class PyPNGImage(BaseImage): """ pyPNG image builder. """ @@ -15,7 +14,10 @@ class PyPNGImage(qrcode.image.base.BaseImage): needs_drawrect = False def new_image(self, **kwargs): - return png.Writer(self.pixel_size, self.pixel_size, greyscale=True, bitdepth=1) + if not PngWriter: + raise ImportError("PyPNG library not installed.") + + return PngWriter(self.pixel_size, self.pixel_size, greyscale=True, bitdepth=1) def drawrect(self, row, col): """ diff --git a/qrcode/image/styledpil.py b/qrcode/image/styledpil.py index 088e1427..aa5917a1 100644 --- a/qrcode/image/styledpil.py +++ b/qrcode/image/styledpil.py @@ -1,8 +1,5 @@ -# Needed on case-insensitive filesystems -from __future__ import absolute_import - import qrcode.image.base -from qrcode.compat.pil import Image +from PIL import Image from qrcode.image.styles.colormasks import QRColorMask, SolidFillColorMask from qrcode.image.styles.moduledrawers import SquareModuleDrawer diff --git a/qrcode/image/styles/colormasks.py b/qrcode/image/styles/colormasks.py index 3b9a8084..43186b27 100644 --- a/qrcode/image/styles/colormasks.py +++ b/qrcode/image/styles/colormasks.py @@ -1,9 +1,6 @@ -# Needed on case-insensitive filesystems -from __future__ import absolute_import - import math -from qrcode.compat.pil import Image +from PIL import Image class QRColorMask: diff --git a/qrcode/image/styles/moduledrawers/base.py b/qrcode/image/styles/moduledrawers/base.py index 8de33059..154d2cfa 100644 --- a/qrcode/image/styles/moduledrawers/base.py +++ b/qrcode/image/styles/moduledrawers/base.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import - import abc from typing import TYPE_CHECKING @@ -32,5 +30,4 @@ def initialize(self, img: "BaseImage") -> None: self.img = img @abc.abstractmethod - def drawrect(self, box, is_active) -> None: - ... + def drawrect(self, box, is_active) -> None: ... diff --git a/qrcode/image/styles/moduledrawers/pil.py b/qrcode/image/styles/moduledrawers/pil.py index 398010c6..89eeffac 100644 --- a/qrcode/image/styles/moduledrawers/pil.py +++ b/qrcode/image/styles/moduledrawers/pil.py @@ -1,9 +1,6 @@ -# Needed on case-insensitive filesystems -from __future__ import absolute_import - from typing import TYPE_CHECKING, List -from qrcode.compat.pil import Image, ImageDraw +from PIL import Image, ImageDraw from qrcode.image.styles.moduledrawers.base import QRModuleDrawer if TYPE_CHECKING: @@ -249,7 +246,9 @@ def setup_edges(self): base_draw = ImageDraw.Draw(base) base_draw.ellipse((0, 0, fake_width * 2, fake_height), fill=front_color) - self.ROUND_LEFT = base.resize((width, shrunken_height), Image.Resampling.LANCZOS) + self.ROUND_LEFT = base.resize( + (width, shrunken_height), Image.Resampling.LANCZOS + ) self.ROUND_RIGHT = self.ROUND_LEFT.transpose(Image.Transpose.FLIP_LEFT_RIGHT) def drawrect(self, box, is_active: "ActiveWithNeighbors"): diff --git a/qrcode/image/styles/moduledrawers/svg.py b/qrcode/image/styles/moduledrawers/svg.py index 6e629759..cf5b9e7d 100644 --- a/qrcode/image/styles/moduledrawers/svg.py +++ b/qrcode/image/styles/moduledrawers/svg.py @@ -60,8 +60,7 @@ def drawrect(self, box, is_active: bool): self.img._img.append(self.el(box)) @abc.abstractmethod - def el(self, box): - ... + def el(self, box): ... class SvgSquareDrawer(SvgQRModuleDrawer): @@ -106,8 +105,7 @@ def drawrect(self, box, is_active: bool): self.img._subpaths.append(self.subpath(box)) @abc.abstractmethod - def subpath(self, box) -> str: - ... + def subpath(self, box) -> str: ... class SvgPathSquareDrawer(SvgPathQRModuleDrawer): diff --git a/qrcode/image/svg.py b/qrcode/image/svg.py index bf0ec870..4ad371bb 100644 --- a/qrcode/image/svg.py +++ b/qrcode/image/svg.py @@ -1,8 +1,6 @@ import decimal from decimal import Decimal -from typing import List, Optional, Type, Union, overload - -from typing_extensions import Literal +from typing import List, Optional, Type, Union, overload, Literal import qrcode.image.base from qrcode.compat.etree import ET @@ -29,12 +27,10 @@ def __init__(self, *args, **kwargs): self.unit_size = self.units(self.box_size) @overload - def units(self, pixels: Union[int, Decimal], text: Literal[False]) -> Decimal: - ... + def units(self, pixels: Union[int, Decimal], text: Literal[False]) -> Decimal: ... @overload - def units(self, pixels: Union[int, Decimal], text: Literal[True] = True) -> str: - ... + def units(self, pixels: Union[int, Decimal], text: Literal[True] = True) -> str: ... def units(self, pixels, text=True): """ diff --git a/qrcode/main.py b/qrcode/main.py index 0ac91bbb..46116b5c 100644 --- a/qrcode/main.py +++ b/qrcode/main.py @@ -10,10 +10,9 @@ TypeVar, cast, overload, + Literal, ) -from typing_extensions import Literal - from qrcode import constants, exceptions, util from qrcode.image.base import BaseImage from qrcode.image.pure import PyPNGImage @@ -193,12 +192,10 @@ def makeImpl(self, test, mask_pattern): def setup_position_probe_pattern(self, row, col): for r in range(-1, 8): - if row + r <= -1 or self.modules_count <= row + r: continue for c in range(-1, 8): - if col + c <= -1 or self.modules_count <= col + c: continue @@ -333,14 +330,14 @@ def get_module(x, y) -> int: out.flush() @overload - def make_image(self, image_factory: Literal[None] = None, **kwargs) -> GenericImage: - ... + def make_image( + self, image_factory: Literal[None] = None, **kwargs + ) -> GenericImage: ... @overload def make_image( self, image_factory: Type[GenericImageLocal] = None, **kwargs - ) -> GenericImageLocal: - ... + ) -> GenericImageLocal: ... def make_image(self, image_factory=None, **kwargs): """ @@ -348,6 +345,12 @@ def make_image(self, image_factory=None, **kwargs): If the data has not been compiled yet, make it first. """ + if ( + kwargs.get("embeded_image_path") or kwargs.get("embeded_image") + ) and self.error_correction != constants.ERROR_CORRECT_H: + raise ValueError( + "Error correction level must be ERROR_CORRECT_H if an embedded image is provided" + ) _check_box_size(self.box_size) if self.data_cache is None: self.make() @@ -406,20 +409,16 @@ def setup_position_adjust_pattern(self): pos = util.pattern_position(self.version) for i in range(len(pos)): - row = pos[i] for j in range(len(pos)): - col = pos[j] if self.modules[row][col] is not None: continue for r in range(-2, 3): - for c in range(-2, 3): - if ( r == -2 or r == 2 @@ -448,7 +447,6 @@ def setup_type_info(self, test, mask_pattern): # vertical for i in range(15): - mod = not test and ((bits >> i) & 1) == 1 if i < 6: @@ -460,7 +458,6 @@ def setup_type_info(self, test, mask_pattern): # horizontal for i in range(15): - mod = not test and ((bits >> i) & 1) == 1 if i < 8: @@ -484,18 +481,14 @@ def map_data(self, data, mask_pattern): data_len = len(data) for col in range(self.modules_count - 1, 0, -2): - if col <= 6: col -= 1 col_range = (col, col - 1) while True: - for c in col_range: - if self.modules[row][c] is None: - dark = False if byteIndex < data_len: diff --git a/qrcode/release.py b/qrcode/release.py index b93de39c..208ac1ee 100644 --- a/qrcode/release.py +++ b/qrcode/release.py @@ -2,6 +2,7 @@ This file provides zest.releaser entrypoints using when releasing new qrcode versions. """ + import os import re import datetime diff --git a/qrcode/tests/consts.py b/qrcode/tests/consts.py new file mode 100644 index 00000000..b1240139 --- /dev/null +++ b/qrcode/tests/consts.py @@ -0,0 +1,4 @@ +UNICODE_TEXT = "\u03b1\u03b2\u03b3" +WHITE = (255, 255, 255) +BLACK = (0, 0, 0) +RED = (255, 0, 0) diff --git a/qrcode/tests/test_example.py b/qrcode/tests/test_example.py index 2e03dc53..7190fe3e 100644 --- a/qrcode/tests/test_example.py +++ b/qrcode/tests/test_example.py @@ -1,13 +1,13 @@ -import unittest from unittest import mock +import pytest + from qrcode import run_example -from qrcode.compat.pil import Image + +pytest.importorskip("PIL", reason="Requires PIL") -class ExampleTest(unittest.TestCase): - @unittest.skipIf(not Image, "Requires PIL") - @mock.patch("PIL.Image.Image.show") - def runTest(self, mock_show): - run_example() - mock_show.assert_called_with() +@mock.patch("PIL.Image.Image.show") +def test_example(mock_show): + run_example() + mock_show.assert_called_with() diff --git a/qrcode/tests/test_qrcode.py b/qrcode/tests/test_qrcode.py index a4e636e8..65242848 100644 --- a/qrcode/tests/test_qrcode.py +++ b/qrcode/tests/test_qrcode.py @@ -1,495 +1,271 @@ import io -import os -import unittest -import warnings -from tempfile import mkdtemp from unittest import mock -import png +import pytest import qrcode import qrcode.util -from qrcode.compat.pil import Image as pil_Image from qrcode.exceptions import DataOverflowError from qrcode.image.base import BaseImage -from qrcode.image.pure import PyPNGImage -from qrcode.image.styledpil import StyledPilImage -from qrcode.image.styles import colormasks, moduledrawers +from qrcode.tests.consts import UNICODE_TEXT from qrcode.util import MODE_8BIT_BYTE, MODE_ALPHA_NUM, MODE_NUMBER, QRData -UNICODE_TEXT = "\u03b1\u03b2\u03b3" -WHITE = (255, 255, 255) -BLACK = (0, 0, 0) -RED = (255, 0, 0) +def test_basic(): + qr = qrcode.QRCode(version=1) + qr.add_data("a") + qr.make(fit=False) -class QRCodeTests(unittest.TestCase): - def setUp(self): - self.tmpdir = mkdtemp() - def tearDown(self): - os.rmdir(self.tmpdir) +def test_large(): + qr = qrcode.QRCode(version=27) + qr.add_data("a") + qr.make(fit=False) - def test_basic(self): - qr = qrcode.QRCode(version=1) - qr.add_data("a") - qr.make(fit=False) - def test_large(self): - qr = qrcode.QRCode(version=27) - qr.add_data("a") - qr.make(fit=False) +def test_invalid_version(): + with pytest.raises(ValueError): + qrcode.QRCode(version=42) - def test_invalid_version(self): - self.assertRaises(ValueError, qrcode.QRCode, version=41) - def test_invalid_border(self): - self.assertRaises(ValueError, qrcode.QRCode, border=-1) +def test_invalid_border(): + with pytest.raises(ValueError): + qrcode.QRCode(border=-1) - def test_overflow(self): - qr = qrcode.QRCode(version=1) - qr.add_data("abcdefghijklmno") - self.assertRaises(DataOverflowError, qr.make, fit=False) - def test_add_qrdata(self): - qr = qrcode.QRCode(version=1) - data = QRData("a") - qr.add_data(data) +def test_overflow(): + qr = qrcode.QRCode(version=1) + qr.add_data("abcdefghijklmno") + with pytest.raises(DataOverflowError): qr.make(fit=False) - def test_fit(self): - qr = qrcode.QRCode() - qr.add_data("a") - qr.make() - self.assertEqual(qr.version, 1) - qr.add_data("bcdefghijklmno") - qr.make() - self.assertEqual(qr.version, 2) - - def test_mode_number(self): - qr = qrcode.QRCode() - qr.add_data("1234567890123456789012345678901234", optimize=0) - qr.make() - self.assertEqual(qr.version, 1) - self.assertEqual(qr.data_list[0].mode, MODE_NUMBER) - - def test_mode_alpha(self): - qr = qrcode.QRCode() - qr.add_data("ABCDEFGHIJ1234567890", optimize=0) - qr.make() - self.assertEqual(qr.version, 1) - self.assertEqual(qr.data_list[0].mode, MODE_ALPHA_NUM) - - def test_regression_mode_comma(self): - qr = qrcode.QRCode() - qr.add_data(",", optimize=0) - qr.make() - self.assertEqual(qr.data_list[0].mode, MODE_8BIT_BYTE) - - def test_mode_8bit(self): - qr = qrcode.QRCode() - qr.add_data("abcABC" + UNICODE_TEXT, optimize=0) - qr.make() - self.assertEqual(qr.version, 1) - self.assertEqual(qr.data_list[0].mode, MODE_8BIT_BYTE) - - def test_mode_8bit_newline(self): - qr = qrcode.QRCode() - qr.add_data("ABCDEFGHIJ1234567890\n", optimize=0) - qr.make() - self.assertEqual(qr.data_list[0].mode, MODE_8BIT_BYTE) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_pil(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image() - img.save(io.BytesIO()) - self.assertIsInstance(img.get_image(), pil_Image.Image) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_pil_with_transparent_background(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(back_color="TransParent") - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_pil_with_red_background(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(back_color="red") - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_pil_with_rgb_color_tuples(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(back_color=(255, 195, 235), fill_color=(55, 95, 35)) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_with_pattern(self): - qr = qrcode.QRCode(mask_pattern=3) - qr.add_data(UNICODE_TEXT) - img = qr.make_image() - img.save(io.BytesIO()) - - def test_make_image_with_wrong_pattern(self): - with self.assertRaises(TypeError): - qrcode.QRCode(mask_pattern="string pattern") - - with self.assertRaises(ValueError): - qrcode.QRCode(mask_pattern=-1) - - with self.assertRaises(ValueError): - qrcode.QRCode(mask_pattern=42) - - def test_mask_pattern_setter(self): - qr = qrcode.QRCode() - - with self.assertRaises(TypeError): - qr.mask_pattern = "string pattern" - - with self.assertRaises(ValueError): - qr.mask_pattern = -1 - - with self.assertRaises(ValueError): - qr.mask_pattern = 8 - - def test_qrcode_bad_factory(self): - with self.assertRaises(TypeError): - qrcode.QRCode(image_factory="not_BaseImage") # type: ignore - - with self.assertRaises(AssertionError): - qrcode.QRCode(image_factory=dict) # type: ignore - - def test_qrcode_factory(self): - class MockFactory(BaseImage): - drawrect = mock.Mock() - new_image = mock.Mock() - - qr = qrcode.QRCode(image_factory=MockFactory) - qr.add_data(UNICODE_TEXT) + +def test_add_qrdata(): + qr = qrcode.QRCode(version=1) + data = QRData("a") + qr.add_data(data) + qr.make(fit=False) + + +def test_fit(): + qr = qrcode.QRCode() + qr.add_data("a") + qr.make() + assert qr.version == 1 + qr.add_data("bcdefghijklmno") + qr.make() + assert qr.version == 2 + + +def test_mode_number(): + qr = qrcode.QRCode() + qr.add_data("1234567890123456789012345678901234", optimize=0) + qr.make() + assert qr.version == 1 + assert qr.data_list[0].mode == MODE_NUMBER + + +def test_mode_alpha(): + qr = qrcode.QRCode() + qr.add_data("ABCDEFGHIJ1234567890", optimize=0) + qr.make() + assert qr.version == 1 + assert qr.data_list[0].mode == MODE_ALPHA_NUM + + +def test_regression_mode_comma(): + qr = qrcode.QRCode() + qr.add_data(",", optimize=0) + qr.make() + assert qr.data_list[0].mode == MODE_8BIT_BYTE + + +def test_mode_8bit(): + qr = qrcode.QRCode() + qr.add_data("abcABC" + UNICODE_TEXT, optimize=0) + qr.make() + assert qr.version == 1 + assert qr.data_list[0].mode == MODE_8BIT_BYTE + + +def test_mode_8bit_newline(): + qr = qrcode.QRCode() + qr.add_data("ABCDEFGHIJ1234567890\n", optimize=0) + qr.make() + assert qr.data_list[0].mode == MODE_8BIT_BYTE + + +def test_make_image_with_wrong_pattern(): + with pytest.raises(TypeError): + qrcode.QRCode(mask_pattern="string pattern") + + with pytest.raises(ValueError): + qrcode.QRCode(mask_pattern=-1) + + with pytest.raises(ValueError): + qrcode.QRCode(mask_pattern=42) + + +def test_mask_pattern_setter(): + qr = qrcode.QRCode() + + with pytest.raises(TypeError): + qr.mask_pattern = "string pattern" + + with pytest.raises(ValueError): + qr.mask_pattern = -1 + + with pytest.raises(ValueError): + qr.mask_pattern = 8 + + +def test_qrcode_bad_factory(): + with pytest.raises(TypeError): + qrcode.QRCode(image_factory="not_BaseImage") # type: ignore + + with pytest.raises(AssertionError): + qrcode.QRCode(image_factory=dict) # type: ignore + + +def test_qrcode_factory(): + class MockFactory(BaseImage): + drawrect = mock.Mock() + new_image = mock.Mock() + + qr = qrcode.QRCode(image_factory=MockFactory) + qr.add_data(UNICODE_TEXT) + qr.make_image() + assert MockFactory.new_image.called + assert MockFactory.drawrect.called + + +def test_optimize(): + qr = qrcode.QRCode() + text = "A1abc12345def1HELLOa" + qr.add_data(text, optimize=4) + qr.make() + assert [d.mode for d in qr.data_list] == [ + MODE_8BIT_BYTE, + MODE_NUMBER, + MODE_8BIT_BYTE, + MODE_ALPHA_NUM, + MODE_8BIT_BYTE, + ] + assert qr.version == 2 + + +def test_optimize_short(): + qr = qrcode.QRCode() + text = "A1abc1234567def1HELLOa" + qr.add_data(text, optimize=7) + qr.make() + assert len(qr.data_list) == 3 + assert [d.mode for d in qr.data_list] == [ + MODE_8BIT_BYTE, + MODE_NUMBER, + MODE_8BIT_BYTE, + ] + assert qr.version == 2 + + +def test_optimize_longer_than_data(): + qr = qrcode.QRCode() + text = "ABCDEFGHIJK" + qr.add_data(text, optimize=12) + assert len(qr.data_list) == 1 + assert qr.data_list[0].mode == MODE_ALPHA_NUM + + +def test_optimize_size(): + text = "A1abc12345123451234512345def1HELLOHELLOHELLOHELLOa" * 5 + + qr = qrcode.QRCode() + qr.add_data(text) + qr.make() + assert qr.version == 10 + + qr = qrcode.QRCode() + qr.add_data(text, optimize=0) + qr.make() + assert qr.version == 11 + + +def test_qrdata_repr(): + data = b"hello" + data_obj = qrcode.util.QRData(data) + assert repr(data_obj) == repr(data) + + +def test_print_ascii_stdout(): + qr = qrcode.QRCode() + with mock.patch("sys.stdout") as fake_stdout: + fake_stdout.isatty.return_value = None + with pytest.raises(OSError): + qr.print_ascii(tty=True) + assert fake_stdout.isatty.called + + +def test_print_ascii(): + qr = qrcode.QRCode(border=0) + f = io.StringIO() + qr.print_ascii(out=f) + printed = f.getvalue() + f.close() + expected = "\u2588\u2580\u2580\u2580\u2580\u2580\u2588" + assert printed[: len(expected)] == expected + + f = io.StringIO() + f.isatty = lambda: True + qr.print_ascii(out=f, tty=True) + printed = f.getvalue() + f.close() + expected = "\x1b[48;5;232m\x1b[38;5;255m" + "\xa0\u2584\u2584\u2584\u2584\u2584\xa0" + assert printed[: len(expected)] == expected + + +def test_print_tty_stdout(): + qr = qrcode.QRCode() + with mock.patch("sys.stdout") as fake_stdout: + fake_stdout.isatty.return_value = None + pytest.raises(OSError, qr.print_tty) + assert fake_stdout.isatty.called + + +def test_print_tty(): + qr = qrcode.QRCode() + f = io.StringIO() + f.isatty = lambda: True + qr.print_tty(out=f) + printed = f.getvalue() + f.close() + BOLD_WHITE_BG = "\x1b[1;47m" + BLACK_BG = "\x1b[40m" + WHITE_BLOCK = BOLD_WHITE_BG + " " + BLACK_BG + EOL = "\x1b[0m\n" + expected = BOLD_WHITE_BG + " " * 23 + EOL + WHITE_BLOCK + " " * 7 + WHITE_BLOCK + assert printed[: len(expected)] == expected + + +def test_get_matrix(): + qr = qrcode.QRCode(border=0) + qr.add_data("1") + assert qr.get_matrix() == qr.modules + + +def test_get_matrix_border(): + qr = qrcode.QRCode(border=1) + qr.add_data("1") + matrix = [row[1:-1] for row in qr.get_matrix()[1:-1]] + assert matrix == qr.modules + + +def test_negative_size_at_construction(): + with pytest.raises(ValueError): + qrcode.QRCode(box_size=-1) + + +def test_negative_size_at_usage(): + qr = qrcode.QRCode() + qr.box_size = -1 + with pytest.raises(ValueError): qr.make_image() - self.assertTrue(MockFactory.new_image.called) - self.assertTrue(MockFactory.drawrect.called) - - def test_render_pypng(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=PyPNGImage) - self.assertIsInstance(img.get_image(), png.Writer) - - print(img.width, img.box_size, img.border) - img.save(io.BytesIO()) - - def test_render_pypng_to_str(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=PyPNGImage) - self.assertIsInstance(img.get_image(), png.Writer) - - mock_open = mock.mock_open() - with mock.patch("qrcode.image.pure.open", mock_open, create=True): - img.save("test_file.png") - mock_open.assert_called_once_with("test_file.png", "wb") - mock_open("test_file.png", "wb").write.assert_called() - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_Image(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=StyledPilImage) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_embeded_image(self): - embeded_img = pil_Image.new("RGB", (10, 10), color="red") - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=StyledPilImage, embeded_image=embeded_img) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_embeded_image_and_ratio(self): - embeded_img = pil_Image.new("RGB", (10, 10), color="red") - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=StyledPilImage, embeded_image=embeded_img, embeded_image_ratio=0.3) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_embeded_image_path(self): - tmpfile = os.path.join(self.tmpdir, "test.png") - embeded_img = pil_Image.new("RGB", (10, 10), color="red") - embeded_img.save(tmpfile) - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=StyledPilImage, embeded_image_path=tmpfile) - img.save(io.BytesIO()) - os.remove(tmpfile) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_square_module_drawer(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image( - image_factory=StyledPilImage, - module_drawer=moduledrawers.SquareModuleDrawer(), - ) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_gapped_module_drawer(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image( - image_factory=StyledPilImage, - module_drawer=moduledrawers.GappedSquareModuleDrawer(), - ) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_circle_module_drawer(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image( - image_factory=StyledPilImage, - module_drawer=moduledrawers.CircleModuleDrawer(), - ) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_rounded_module_drawer(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image( - image_factory=StyledPilImage, - module_drawer=moduledrawers.RoundedModuleDrawer(), - ) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_vertical_bars_module_drawer(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image( - image_factory=StyledPilImage, - module_drawer=moduledrawers.VerticalBarsDrawer(), - ) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_horizontal_bars_module_drawer(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - img = qr.make_image( - image_factory=StyledPilImage, - module_drawer=moduledrawers.HorizontalBarsDrawer(), - ) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_default_solid_color_mask(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.SolidFillColorMask() - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_solid_color_mask(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.SolidFillColorMask(back_color=WHITE, front_color=RED) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_color_mask_with_transparency(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.SolidFillColorMask( - back_color=(255, 0, 255, 255), front_color=RED - ) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - assert img.mode == "RGBA" - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_radial_gradient_color_mask(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.RadialGradiantColorMask( - back_color=WHITE, center_color=BLACK, edge_color=RED - ) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_square_gradient_color_mask(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.SquareGradiantColorMask( - back_color=WHITE, center_color=BLACK, edge_color=RED - ) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_horizontal_gradient_color_mask(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.HorizontalGradiantColorMask( - back_color=WHITE, left_color=RED, right_color=BLACK - ) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_vertical_gradient_color_mask(self): - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.VerticalGradiantColorMask( - back_color=WHITE, top_color=RED, bottom_color=BLACK - ) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - @unittest.skipIf(not pil_Image, "Requires PIL") - def test_render_styled_with_image_color_mask(self): - img_mask = pil_Image.new("RGB", (10, 10), color="red") - qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) - qr.add_data(UNICODE_TEXT) - mask = colormasks.ImageColorMask(back_color=WHITE, color_mask_image=img_mask) - img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) - img.save(io.BytesIO()) - - def test_optimize(self): - qr = qrcode.QRCode() - text = "A1abc12345def1HELLOa" - qr.add_data(text, optimize=4) - qr.make() - self.assertEqual( - [d.mode for d in qr.data_list], - [ - MODE_8BIT_BYTE, - MODE_NUMBER, - MODE_8BIT_BYTE, - MODE_ALPHA_NUM, - MODE_8BIT_BYTE, - ], - ) - self.assertEqual(qr.version, 2) - - def test_optimize_short(self): - qr = qrcode.QRCode() - text = "A1abc1234567def1HELLOa" - qr.add_data(text, optimize=7) - qr.make() - self.assertEqual(len(qr.data_list), 3) - self.assertEqual( - [d.mode for d in qr.data_list], - [MODE_8BIT_BYTE, MODE_NUMBER, MODE_8BIT_BYTE], - ) - self.assertEqual(qr.version, 2) - - def test_optimize_longer_than_data(self): - qr = qrcode.QRCode() - text = "ABCDEFGHIJK" - qr.add_data(text, optimize=12) - self.assertEqual(len(qr.data_list), 1) - self.assertEqual(qr.data_list[0].mode, MODE_ALPHA_NUM) - - def test_optimize_size(self): - text = "A1abc12345123451234512345def1HELLOHELLOHELLOHELLOa" * 5 - - qr = qrcode.QRCode() - qr.add_data(text) - qr.make() - self.assertEqual(qr.version, 10) - - qr = qrcode.QRCode() - qr.add_data(text, optimize=0) - qr.make() - self.assertEqual(qr.version, 11) - - def test_qrdata_repr(self): - data = b"hello" - data_obj = qrcode.util.QRData(data) - self.assertEqual(repr(data_obj), repr(data)) - - def test_print_ascii_stdout(self): - qr = qrcode.QRCode() - with mock.patch("sys.stdout") as fake_stdout: - fake_stdout.isatty.return_value = None - self.assertRaises(OSError, qr.print_ascii, tty=True) - self.assertTrue(fake_stdout.isatty.called) - - def test_print_ascii(self): - qr = qrcode.QRCode(border=0) - f = io.StringIO() - qr.print_ascii(out=f) - printed = f.getvalue() - f.close() - expected = "\u2588\u2580\u2580\u2580\u2580\u2580\u2588" - self.assertEqual(printed[: len(expected)], expected) - - f = io.StringIO() - f.isatty = lambda: True - qr.print_ascii(out=f, tty=True) - printed = f.getvalue() - f.close() - expected = ( - "\x1b[48;5;232m\x1b[38;5;255m" + "\xa0\u2584\u2584\u2584\u2584\u2584\xa0" - ) - self.assertEqual(printed[: len(expected)], expected) - - def test_print_tty_stdout(self): - qr = qrcode.QRCode() - with mock.patch("sys.stdout") as fake_stdout: - fake_stdout.isatty.return_value = None - self.assertRaises(OSError, qr.print_tty) - self.assertTrue(fake_stdout.isatty.called) - - def test_print_tty(self): - qr = qrcode.QRCode() - f = io.StringIO() - f.isatty = lambda: True - qr.print_tty(out=f) - printed = f.getvalue() - f.close() - BOLD_WHITE_BG = "\x1b[1;47m" - BLACK_BG = "\x1b[40m" - WHITE_BLOCK = BOLD_WHITE_BG + " " + BLACK_BG - EOL = "\x1b[0m\n" - expected = ( - BOLD_WHITE_BG + " " * 23 + EOL + WHITE_BLOCK + " " * 7 + WHITE_BLOCK - ) - self.assertEqual(printed[: len(expected)], expected) - - def test_get_matrix(self): - qr = qrcode.QRCode(border=0) - qr.add_data("1") - self.assertEqual(qr.get_matrix(), qr.modules) - - def test_get_matrix_border(self): - qr = qrcode.QRCode(border=1) - qr.add_data("1") - matrix = [row[1:-1] for row in qr.get_matrix()[1:-1]] - self.assertEqual(matrix, qr.modules) - - def test_negative_size_at_construction(self): - self.assertRaises(ValueError, qrcode.QRCode, box_size=-1) - - def test_negative_size_at_usage(self): - qr = qrcode.QRCode() - qr.box_size = -1 - self.assertRaises(ValueError, qr.make_image) - - -class ShortcutTest(unittest.TestCase): - @unittest.skipIf(not pil_Image, "Requires PIL") - def runTest(self): - qrcode.make("image") diff --git a/qrcode/tests/test_qrcode_pil.py b/qrcode/tests/test_qrcode_pil.py new file mode 100644 index 00000000..d4a59e15 --- /dev/null +++ b/qrcode/tests/test_qrcode_pil.py @@ -0,0 +1,157 @@ +import io + +import pytest + +import qrcode +import qrcode.util +from qrcode.tests.consts import BLACK, RED, UNICODE_TEXT, WHITE + +Image = pytest.importorskip("PIL.Image", reason="PIL is not installed") + +if Image: + from qrcode.image.styledpil import StyledPilImage + from qrcode.image.styles import colormasks, moduledrawers + + +def test_render_pil(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image() + img.save(io.BytesIO()) + assert isinstance(img.get_image(), Image.Image) + + +@pytest.mark.parametrize("back_color", ["TransParent", "red", (255, 195, 235)]) +def test_render_pil_background(back_color): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(back_color="TransParent") + img.save(io.BytesIO()) + + +def test_render_pil_with_rgb_color_tuples(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(back_color=(255, 195, 235), fill_color=(55, 95, 35)) + img.save(io.BytesIO()) + + +def test_render_with_pattern(): + qr = qrcode.QRCode(mask_pattern=3) + qr.add_data(UNICODE_TEXT) + img = qr.make_image() + img.save(io.BytesIO()) + + +def test_render_styled_Image(): + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=StyledPilImage) + img.save(io.BytesIO()) + + +def test_render_styled_with_embeded_image(): + embeded_img = Image.new("RGB", (10, 10), color="red") + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=StyledPilImage, embeded_image=embeded_img) + img.save(io.BytesIO()) + + +def test_render_styled_with_embeded_image_path(tmp_path): + tmpfile = str(tmp_path / "test.png") + embeded_img = Image.new("RGB", (10, 10), color="red") + embeded_img.save(tmpfile) + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=StyledPilImage, embeded_image_path=tmpfile) + img.save(io.BytesIO()) + + +@pytest.mark.parametrize( + "drawer", + [ + moduledrawers.CircleModuleDrawer, + moduledrawers.GappedSquareModuleDrawer, + moduledrawers.HorizontalBarsDrawer, + moduledrawers.RoundedModuleDrawer, + moduledrawers.SquareModuleDrawer, + moduledrawers.VerticalBarsDrawer, + ], +) +def test_render_styled_with_drawer(drawer): + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) + qr.add_data(UNICODE_TEXT) + img = qr.make_image( + image_factory=StyledPilImage, + module_drawer=drawer(), + ) + img.save(io.BytesIO()) + + +@pytest.mark.parametrize( + "mask", + [ + colormasks.SolidFillColorMask(), + colormasks.SolidFillColorMask(back_color=WHITE, front_color=RED), + colormasks.SolidFillColorMask(back_color=(255, 0, 255, 255), front_color=RED), + colormasks.RadialGradiantColorMask( + back_color=WHITE, center_color=BLACK, edge_color=RED + ), + colormasks.SquareGradiantColorMask( + back_color=WHITE, center_color=BLACK, edge_color=RED + ), + colormasks.HorizontalGradiantColorMask( + back_color=WHITE, left_color=RED, right_color=BLACK + ), + colormasks.VerticalGradiantColorMask( + back_color=WHITE, top_color=RED, bottom_color=BLACK + ), + colormasks.ImageColorMask( + back_color=WHITE, color_mask_image=Image.new("RGB", (10, 10), color="red") + ), + ], +) +def test_render_styled_with_mask(mask): + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=StyledPilImage, color_mask=mask) + img.save(io.BytesIO()) + + +def test_embedded_image_and_error_correction(tmp_path): + "If an embedded image is specified, error correction must be the highest so the QR code is readable" + tmpfile = str(tmp_path / "test.png") + embedded_img = Image.new("RGB", (10, 10), color="red") + embedded_img.save(tmpfile) + + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_L) + qr.add_data(UNICODE_TEXT) + with pytest.raises(ValueError): + qr.make_image(embeded_image_path=tmpfile) + with pytest.raises(ValueError): + qr.make_image(embeded_image=embedded_img) + + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_M) + qr.add_data(UNICODE_TEXT) + with pytest.raises(ValueError): + qr.make_image(embeded_image_path=tmpfile) + with pytest.raises(ValueError): + qr.make_image(embeded_image=embedded_img) + + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_Q) + qr.add_data(UNICODE_TEXT) + with pytest.raises(ValueError): + qr.make_image(embeded_image_path=tmpfile) + with pytest.raises(ValueError): + qr.make_image(embeded_image=embedded_img) + + # The only accepted correction level when an embedded image is provided + qr = qrcode.QRCode(error_correction=qrcode.ERROR_CORRECT_H) + qr.add_data(UNICODE_TEXT) + qr.make_image(embeded_image_path=tmpfile) + qr.make_image(embeded_image=embedded_img) + + +def test_shortcut(): + qrcode.make("image") diff --git a/qrcode/tests/test_qrcode_pypng.py b/qrcode/tests/test_qrcode_pypng.py new file mode 100644 index 00000000..c502a3b3 --- /dev/null +++ b/qrcode/tests/test_qrcode_pypng.py @@ -0,0 +1,35 @@ +import io +from unittest import mock + +import pytest + + +import qrcode +import qrcode.util +from qrcode.image.pure import PyPNGImage +from qrcode.tests.consts import UNICODE_TEXT + +png = pytest.importorskip("png", reason="png is not installed") + + +def test_render_pypng(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=PyPNGImage) + assert isinstance(img.get_image(), png.Writer) + + print(img.width, img.box_size, img.border) + img.save(io.BytesIO()) + + +def test_render_pypng_to_str(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=PyPNGImage) + assert isinstance(img.get_image(), png.Writer) + + mock_open = mock.mock_open() + with mock.patch("qrcode.image.pure.open", mock_open, create=True): + img.save("test_file.png") + mock_open.assert_called_once_with("test_file.png", "wb") + mock_open("test_file.png", "wb").write.assert_called() diff --git a/qrcode/tests/test_qrcode_svg.py b/qrcode/tests/test_qrcode_svg.py index 74b5c553..4774b245 100644 --- a/qrcode/tests/test_qrcode_svg.py +++ b/qrcode/tests/test_qrcode_svg.py @@ -1,60 +1,54 @@ import io -import os -import unittest -from tempfile import mkdtemp import qrcode from qrcode.image import svg - -UNICODE_TEXT = "\u03b1\u03b2\u03b3" +from qrcode.tests.consts import UNICODE_TEXT class SvgImageWhite(svg.SvgImage): background = "white" -class QRCodeSvgTests(unittest.TestCase): - def setUp(self): - self.tmpdir = mkdtemp() - - def tearDown(self): - os.rmdir(self.tmpdir) - - def test_render_svg(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=svg.SvgImage) - img.save(io.BytesIO()) - - def test_render_svg_path(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=svg.SvgPathImage) - img.save(io.BytesIO()) - - def test_render_svg_fragment(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=svg.SvgFragmentImage) - img.save(io.BytesIO()) - - def test_svg_string(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=svg.SvgFragmentImage) - file_like = io.BytesIO() - img.save(file_like) - file_like.seek(0) - assert file_like.read() in img.to_string() - - def test_render_svg_with_background(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=SvgImageWhite) - img.save(io.BytesIO()) - - def test_svg_circle_drawer(self): - qr = qrcode.QRCode() - qr.add_data(UNICODE_TEXT) - img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer="circle") - img.save(io.BytesIO()) +def test_render_svg(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=svg.SvgImage) + img.save(io.BytesIO()) + + +def test_render_svg_path(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=svg.SvgPathImage) + img.save(io.BytesIO()) + + +def test_render_svg_fragment(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=svg.SvgFragmentImage) + img.save(io.BytesIO()) + + +def test_svg_string(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=svg.SvgFragmentImage) + file_like = io.BytesIO() + img.save(file_like) + file_like.seek(0) + assert file_like.read() in img.to_string() + + +def test_render_svg_with_background(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=SvgImageWhite) + img.save(io.BytesIO()) + + +def test_svg_circle_drawer(): + qr = qrcode.QRCode() + qr.add_data(UNICODE_TEXT) + img = qr.make_image(image_factory=svg.SvgPathImage, module_drawer="circle") + img.save(io.BytesIO()) diff --git a/qrcode/tests/test_release.py b/qrcode/tests/test_release.py index 39a267c8..d61454b6 100644 --- a/qrcode/tests/test_release.py +++ b/qrcode/tests/test_release.py @@ -1,7 +1,6 @@ -import re import builtins import datetime -import unittest +import re from unittest import mock from qrcode.release import update_manpage @@ -10,31 +9,35 @@ DATA = 'test\n.TH "date" "version" "description"\nthis' -class UpdateManpageTests(unittest.TestCase): - @mock.patch(OPEN, new_callable=mock.mock_open, read_data=".TH invalid") - def test_invalid_data(self, mock_file): - update_manpage({"name": "qrcode", "new_version": "1.23"}) - mock_file.assert_called() - mock_file().write.assert_not_called() - - @mock.patch(OPEN, new_callable=mock.mock_open, read_data=DATA) - def test_not_qrcode(self, mock_file): - update_manpage({"name": "not-qrcode"}) - mock_file.assert_not_called() - - @mock.patch(OPEN, new_callable=mock.mock_open, read_data=DATA) - def test_no_change(self, mock_file): - update_manpage({"name": "qrcode", "new_version": "version"}) - mock_file.assert_called() - mock_file().write.assert_not_called() - - @mock.patch(OPEN, new_callable=mock.mock_open, read_data=DATA) - def test_change(self, mock_file): - update_manpage({"name": "qrcode", "new_version": "3.11"}) - expected = re.split(r"([^\n]*(?:\n|$))", DATA)[1::2] - expected[1] = ( - expected[1] - .replace("version", "3.11") - .replace("date", datetime.datetime.now().strftime("%-d %b %Y")) - ) - mock_file().write.has_calls([mock.call(line) for line in expected]) +@mock.patch(OPEN, new_callable=mock.mock_open, read_data=".TH invalid") +def test_invalid_data(mock_file): + update_manpage({"name": "qrcode", "new_version": "1.23"}) + mock_file.assert_called() + mock_file().write.assert_not_called() + + +@mock.patch(OPEN, new_callable=mock.mock_open, read_data=DATA) +def test_not_qrcode(mock_file): + update_manpage({"name": "not-qrcode"}) + mock_file.assert_not_called() + + +@mock.patch(OPEN, new_callable=mock.mock_open, read_data=DATA) +def test_no_change(mock_file): + update_manpage({"name": "qrcode", "new_version": "version"}) + mock_file.assert_called() + mock_file().write.assert_not_called() + + +@mock.patch(OPEN, new_callable=mock.mock_open, read_data=DATA) +def test_change(mock_file): + update_manpage({"name": "qrcode", "new_version": "3.11"}) + expected = re.split(r"([^\n]*(?:\n|$))", DATA)[1::2] + expected[1] = ( + expected[1] + .replace("version", "3.11") + .replace("date", datetime.datetime.now().strftime("%-d %b %Y")) + ) + mock_file().write.assert_has_calls( + [mock.call(line) for line in expected if line != ""], any_order=True + ) diff --git a/qrcode/tests/test_script.py b/qrcode/tests/test_script.py index 4ae4ccbc..d6338ded 100644 --- a/qrcode/tests/test_script.py +++ b/qrcode/tests/test_script.py @@ -1,11 +1,8 @@ -import io -import os import sys -import unittest -from tempfile import mkdtemp from unittest import mock -from qrcode.compat.pil import Image +import pytest + from qrcode.console_scripts import commas, main @@ -13,93 +10,88 @@ def bad_read(): raise UnicodeDecodeError("utf-8", b"0x80", 0, 1, "invalid start byte") -class ScriptTest(unittest.TestCase): - def setUp(self): - self.tmpdir = mkdtemp() - - def tearDown(self): - os.rmdir(self.tmpdir) - - @mock.patch("os.isatty", lambda *args: True) - @mock.patch("qrcode.main.QRCode.print_ascii") - def test_isatty(self, mock_print_ascii): - main(["testtext"]) - mock_print_ascii.assert_called_with(tty=True) - - @mock.patch("os.isatty", lambda *args: False) - @mock.patch("sys.stdout") - @unittest.skipIf(not Image, "Requires PIL") - def test_piped(self, mock_stdout): - main(["testtext"]) - - @mock.patch("os.isatty", lambda *args: True) - @mock.patch("qrcode.main.QRCode.print_ascii") - @mock.patch("sys.stdin") - def test_stdin(self, mock_stdin, mock_print_ascii): - mock_stdin.buffer.read.return_value = "testtext" - main([]) - self.assertTrue(mock_stdin.buffer.read.called) - mock_print_ascii.assert_called_with(tty=True) - - @mock.patch("os.isatty", lambda *args: True) - @mock.patch("qrcode.main.QRCode.print_ascii") - def test_stdin_py3_unicodedecodeerror(self, mock_print_ascii): - mock_stdin = mock.Mock(sys.stdin) - mock_stdin.buffer.read.return_value = "testtext" - mock_stdin.read.side_effect = bad_read - with mock.patch("sys.stdin", mock_stdin): +@mock.patch("os.isatty", lambda *args: True) +@mock.patch("qrcode.main.QRCode.print_ascii") +def test_isatty(mock_print_ascii): + main(["testtext"]) + mock_print_ascii.assert_called_with(tty=True) + + +@mock.patch("os.isatty", lambda *args: False) +def test_piped(): + pytest.importorskip("PIL", reason="Requires PIL") + main(["testtext"]) + + +@mock.patch("os.isatty", lambda *args: True) +def test_stdin(): + with mock.patch("qrcode.main.QRCode.print_ascii") as mock_print_ascii: + with mock.patch("sys.stdin") as mock_stdin: + mock_stdin.buffer.read.return_value = "testtext" + main([]) + assert mock_stdin.buffer.read.called + mock_print_ascii.assert_called_with(tty=True) + + +@mock.patch("os.isatty", lambda *args: True) +def test_stdin_py3_unicodedecodeerror(): + with mock.patch("qrcode.main.QRCode.print_ascii") as mock_print_ascii: + with mock.patch("sys.stdin") as mock_stdin: + mock_stdin.buffer.read.return_value = "testtext" + mock_stdin.read.side_effect = bad_read # sys.stdin.read() will raise an error... - self.assertRaises(UnicodeDecodeError, sys.stdin.read) + with pytest.raises(UnicodeDecodeError): + sys.stdin.read() # ... but it won't be used now. main([]) - mock_print_ascii.assert_called_with(tty=True) - - @mock.patch("os.isatty", lambda *args: True) - @mock.patch("qrcode.main.QRCode.print_ascii") - def test_optimize(self, mock_print_ascii): - main("testtext --optimize 0".split()) - - @mock.patch("sys.stdout") - def test_factory(self, mock_stdout): - main("testtext --factory svg".split()) - - @mock.patch("sys.stderr") - def test_bad_factory(self, mock_stderr): - self.assertRaises(SystemExit, main, "testtext --factory fish".split()) - - @mock.patch.object(sys, "argv", "qr testtext output".split()) - @unittest.skipIf(not Image, "Requires PIL") - def test_sys_argv(self): - main() - - @unittest.skipIf(not Image, "Requires PIL") - def test_output(self): - tmpfile = os.path.join(self.tmpdir, "test.png") - main(["testtext", "--output", tmpfile]) - os.remove(tmpfile) - - @mock.patch("sys.stderr", new_callable=io.StringIO) - @unittest.skipIf(not Image, "Requires PIL") - def test_factory_drawer_none(self, mock_stderr): - with self.assertRaises(SystemExit): - main("testtext --factory pil --factory-drawer nope".split()) - self.assertIn( - "The selected factory has no drawer aliases", mock_stderr.getvalue() - ) - - @mock.patch("sys.stderr", new_callable=io.StringIO) - def test_factory_drawer_bad(self, mock_stderr): - with self.assertRaises(SystemExit): - main("testtext --factory svg --factory-drawer sobad".split()) - self.assertIn("sobad factory drawer not found", mock_stderr.getvalue()) - - @mock.patch("sys.stderr", new_callable=io.StringIO) - def test_factory_drawer(self, mock_stderr): - main("testtext --factory svg --factory-drawer circle".split()) - - def test_commas(self): - self.assertEqual(commas([]), "") - self.assertEqual(commas(["A"]), "A") - self.assertEqual(commas("AB"), "A or B") - self.assertEqual(commas("ABC"), "A, B or C") - self.assertEqual(commas("ABC", joiner="and"), "A, B and C") + mock_print_ascii.assert_called_with(tty=True) + + +def test_optimize(): + pytest.importorskip("PIL", reason="Requires PIL") + main("testtext --optimize 0".split()) + + +def test_factory(): + main(["testtext", "--factory", "svg"]) + + +def test_bad_factory(): + with pytest.raises(SystemExit): + main(["testtext", "--factory", "nope"]) + + +@mock.patch.object(sys, "argv", "qr testtext output".split()) +def test_sys_argv(): + pytest.importorskip("PIL", reason="Requires PIL") + main() + + +def test_output(tmp_path): + pytest.importorskip("PIL", reason="Requires PIL") + main(["testtext", "--output", str(tmp_path / "test.png")]) + + +def test_factory_drawer_none(capsys): + pytest.importorskip("PIL", reason="Requires PIL") + with pytest.raises(SystemExit): + main("testtext --factory pil --factory-drawer nope".split()) + assert "The selected factory has no drawer aliases" in capsys.readouterr()[1] + + +def test_factory_drawer_bad(capsys): + with pytest.raises(SystemExit): + main("testtext --factory svg --factory-drawer sobad".split()) + assert "sobad factory drawer not found" in capsys.readouterr()[1] + + +def test_factory_drawer(capsys): + main("testtext --factory svg --factory-drawer circle".split()) + + +def test_commas(): + assert commas([]) == "" + assert commas(["A"]) == "A" + assert commas("AB") == "A or B" + assert commas("ABC") == "A, B or C" + assert commas("ABC", joiner="and") == "A, B and C" diff --git a/qrcode/tests/test_util.py b/qrcode/tests/test_util.py index 1c98e425..e57badbc 100644 --- a/qrcode/tests/test_util.py +++ b/qrcode/tests/test_util.py @@ -1,12 +1,11 @@ -import unittest +import pytest from qrcode import util -class UtilTests(unittest.TestCase): - def test_check_wrong_version(self): - with self.assertRaises(ValueError): - util.check_version(0) +def test_check_wrong_version(): + with pytest.raises(ValueError): + util.check_version(0) - with self.assertRaises(ValueError): - util.check_version(41) + with pytest.raises(ValueError): + util.check_version(41) diff --git a/qrcode/util.py b/qrcode/util.py index 8284003b..02fe11d1 100644 --- a/qrcode/util.py +++ b/qrcode/util.py @@ -549,7 +549,6 @@ def create_bytes(buffer: BitBuffer, rs_blocks: List[RSBlock]): def create_data(version, error_correction, data_list): - buffer = BitBuffer() for data in data_list: buffer.put(data.mode, 4) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3aff842a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,99 +0,0 @@ -[metadata] -name = qrcode -version = 7.5.dev0 -description = QR Code image generator -long_description = file: README.rst, CHANGES.rst -author = Lincoln Loop -author_email = info@lincolnloop.com -url = https://github.com/lincolnloop/python-qrcode -keywords = qr denso-wave IEC18004 -license = BSD -classifiers = - Development Status :: 5 - Production/Stable - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Intended Audience :: Developers - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3 :: Only - Topic :: Multimedia :: Graphics - Topic :: Software Development :: Libraries :: Python Modules - -[options] -zip_safe = False -include_package_data = True -packages = find: -install_requires = - colorama;platform_system=="Windows" - typing_extensions - pypng -python_requires = >= 3.7 - -[options.extras_require] -maintainer = - zest.releaser[recommended] -dev = - tox - pytest - pytest-cov -test = - coverage - pytest -pil = - pillow>=9.1.0 -all = - zest.releaser[recommended] - tox - pytest - pytest - pytest-cov - pillow>=9.1.0 - -[options.entry_points] -console_scripts = - qr = qrcode.console_scripts:main - -[bdist_wheel] -# Having this section in here will trigger zest to build a wheel. -universal = 0 - -[flake8] -exclude = - .tox - .git - __pycache__ - build - dist -max-line-length = 88 -ignore = E203,W503 - -[isort] -profile = black - -[coverage:run] -source = qrcode -parallel = True - -[coverage:report] -exclude_lines = - pragma: no cover - @overload - if (typing\.)?TYPE_CHECKING: -skip_covered = True - -[zest.releaser] -less-zeros = yes -version-levels = 2 -tag-format = v{version} -tag-message = Version {version} -tag-signing = yes -date-format = %%-d %%B %%Y -prereleaser.middle = - qrcode.release.update_manpage - -[tool:pytest] -filterwarnings = module diff --git a/setup.py b/setup.py deleted file mode 100644 index d002ffb8..00000000 --- a/setup.py +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env python -from setuptools import setup - -# See setup.cfg for configuration. -setup( - data_files=[('share/man/man1', ['doc/qr.1'])], -) diff --git a/tox.ini b/tox.ini index 9967a1f5..96d72d0d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,55 +1,20 @@ [tox] distribute = False -envlist = - coverage_setup - nopil - py{36,37,38,39,310} - readme - coverage_report +envlist = py{39,310,311,312}-{pil,png,none} skip_missing_interpreters = True [gh-actions] python = - 3.7: py37 - 3.8: py38 - 3.9: py39, readme, nopil + 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312 [testenv] -depends = coverage_setup -usedevelop = True -extras = - test - pil -commands = coverage run -m pytest - -[testenv:nopil] -extras = - test - -[testenv:readme] -skip_install = True -deps = - docutils - Pygments commands = - {envbindir}/rst2html.py --report=info --halt=warning README.rst /dev/null - {envbindir}/rst2html.py --report=info --halt=warning CHANGES.rst /dev/null - -[testenv:coverage_setup] -depends = -skip_install = True -deps = coverage -commands = coverage erase - -[testenv:coverage_report] -depends = - py{36,37,38,39,310} - nopil -skip_install = True -deps = coverage -commands = - coverage combine - coverage html - coverage report --omit="qrcode/tests/*" --fail-under=98 -m - coverage report --include="qrcode/tests/*" --no-skip-covered --fail-under=100 -m + pytest --cov +deps = + pil: pillow>=9.1.0 + png: pypng + pytest + pytest-cov