├── .editorconfig ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── example.svg ├── poetry.lock ├── pyproject.toml ├── qrplatba ├── __init__.py ├── spayd.py └── svg.py └── tests └── test_svg.py /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 120 11 | 12 | [{*.yml,*.yaml}] 13 | indent_size = 2 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'README.md' 7 | - 'LICENSE' 8 | - 'example.svg' 9 | branches: 10 | - master 11 | pull_request: 12 | paths-ignore: 13 | - 'README.md' 14 | - 'LICENSE' 15 | - 'example.svg' 16 | branches: 17 | - '**' 18 | 19 | concurrency: 20 | group: tests-${{ github.head_ref || github.ref }} 21 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 22 | 23 | jobs: 24 | tests: 25 | name: "pytest: Python ${{ matrix.python-version }}" 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | python-version: [ "3.8", "3.9", "3.10", "3.11" ] 30 | defaults: 31 | run: 32 | shell: bash 33 | steps: 34 | - uses: actions/checkout@v3 35 | 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | 41 | - name: Get full Python version 42 | id: full-python-version 43 | run: echo version=$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") >> $GITHUB_OUTPUT 44 | 45 | - name: Bootstrap poetry 46 | run: | 47 | curl -sSL https://install.python-poetry.org | python - -y 48 | 49 | - name: Update PATH 50 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 51 | 52 | - name: Configure poetry 53 | run: poetry config virtualenvs.in-project true 54 | 55 | - name: Set up cache 56 | uses: actions/cache@v3 57 | id: cache 58 | with: 59 | path: .venv 60 | key: venv-${{ runner.os }}-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }} 61 | 62 | - name: Ensure cache is healthy 63 | if: steps.cache.outputs.cache-hit == 'true' 64 | run: | 65 | # `timeout` is not available on macOS, so we define a custom function. 66 | [ "$(command -v timeout)" ] || function timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; } 67 | # Using `timeout` is a safeguard against the Poetry command hanging for some reason. 68 | timeout 10s poetry run pip --version || rm -rf .venv 69 | - name: Check lock file 70 | run: poetry lock --check 71 | 72 | - name: Install dependencies 73 | run: poetry install --with test 74 | 75 | - name: Run pytest 76 | run: poetry run pytest -v 77 | 78 | - name: Run ruff 79 | run: poetry run ruff . 80 | 81 | - name: Run black 82 | run: poetry run black --check . 83 | 84 | - name: Check for clean working tree 85 | run: | 86 | git diff --exit-code --stat HEAD 87 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 3.11 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: "3.11" 20 | 21 | - name: Install Poetry 22 | run: | 23 | curl -sSL https://install.python-poetry.org | python - -y 24 | - name: Update PATH 25 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 26 | 27 | - name: Build project for distribution 28 | run: poetry build 29 | 30 | - name: Create Release 31 | uses: ncipollo/release-action@v1 32 | with: 33 | artifacts: "dist/*" 34 | token: ${{ secrets.GITHUB_TOKEN }} 35 | draft: false 36 | 37 | - name: Publish to PyPI 38 | env: 39 | POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} 40 | run: poetry publish 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.pyc 3 | dist/ 4 | .idea/ 5 | qrplatba.egg-info/ 6 | .*_cache/ 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | language_version: python3.11 7 | - repo: https://github.com/charliermarsh/ruff-pre-commit 8 | rev: 'v0.0.260' 9 | hooks: 10 | - id: ruff 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Viktor Stískala 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-qrplatba 2 | 3 | [![Stable Version](https://img.shields.io/pypi/v/qrplatba?label=stable)](https://pypi.org/project/qrplatba/#description) 4 | 5 | Python library for generating QR codes for QR platba. 6 | 7 | ![https://raw.github.com/viktorstiskala/python-qrplatba/gh-pages/example.png](http://viktorstiskala.github.io/python-qrplatba/example.svg) 8 | 9 | See http://qr-platba.cz/pro-vyvojare/ for more information about the specification (available only in czech). 10 | 11 | ```python 12 | from qrplatba import QRPlatbaGenerator 13 | from datetime import datetime, timedelta 14 | 15 | 16 | due = datetime.now() + timedelta(days=14) 17 | generator = QRPlatbaGenerator('123456789/0123', 400.56, x_vs=2034456, message='text', due_date=due) 18 | img = generator.make_image() 19 | img.save('example.svg') 20 | 21 | # optional: custom box size and border 22 | img = generator.make_image(box_size=20, border=4) 23 | 24 | # optional: get SVG as a string. 25 | # Encoding has to be 'unicode', otherwise it will be encoded as bytes 26 | svg_data = img.to_string(encoding='unicode') 27 | ``` 28 | 29 | ## Installation 30 | 31 | To install qrplatba, simply: 32 | 33 | ```bash 34 | $ pip install qrplatba 35 | ``` 36 | 37 | ## Note on image file formats 38 | 39 | This module generates SVG file which is an XML-based vector image format. You can use various libraries and/or utilities to convert it to other vector or bitmap image formats. Below is an example how to use ``libRSVG`` to convert SVG images. 40 | 41 | ### libRSVG 42 | 43 | [`libRSVG`](https://wiki.gnome.org/action/show/Projects/LibRsvg?action=show) renders SVG files using cairo and supports many output image formats. It can also be used directly in console with ``rsvg-convert`` command. 44 | 45 | ```bash 46 | $ rsvg-convert -f pdf example.svg -o example.pdf 47 | ``` 48 | 49 | ## SPAYD format 50 | 51 | QR Platba uses SPAYD format (`application/x-shortpaymentdescriptor`) for encoding information related to bank transfer. In addition to generating QR codes, this library can also generate just the encoded string using the following code: 52 | 53 | ```python 54 | generator = QRPlatbaGenerator('123456789/0123', 400.56, x_vs=2034456, message='text', due_date=due) 55 | spayd = generator.get_text() 56 | ``` 57 | 58 | ## License 59 | 60 | This software is licensed under [MIT license](https://opensource.org/license/mit/) since version `1.0.0`. 61 | 62 | ## Changelog 63 | 64 | ### `1.1.1` (24 April 2023) 65 | - Added compatibility with `lxml` library. Fixes `TypeError` when using this library while `lxml` is installed in the same virtualenv. 66 | 67 | ### `1.1.0` (5 April 2023) 68 | 69 | - Dropped support for Python 3.7 70 | - Added pre-commit, black and ruff for code formatting 71 | 72 | ### `1.0.0` (4 April 2023) 73 | 74 | **Warning:** While the API is mostly backwards compatible, the look and size of the generated QR codes has changed. 75 | 76 | - Updated requirements to support the latest `qrcode` version 77 | - Added support for custom output sizes using `box_size` and `border` parameters 78 | - Changed legacy setuptools to [poetry](https://python-poetry.org/) 79 | - Dropped support for Python `2.x` and `<3.7` 80 | - Changed license to MIT 81 | - Added unit tests 82 | 83 | 84 | 85 | -------------------------------------------------------------------------------- /example.svg: -------------------------------------------------------------------------------- 1 | 2 | QR platba -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "black" 5 | version = "23.3.0" 6 | description = "The uncompromising code formatter." 7 | category = "dev" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:0945e13506be58bf7db93ee5853243eb368ace1c08a24c65ce108986eac65915"}, 12 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:67de8d0c209eb5b330cce2469503de11bca4085880d62f1628bd9972cc3366b9"}, 13 | {file = "black-23.3.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:7c3eb7cea23904399866c55826b31c1f55bbcd3890ce22ff70466b907b6775c2"}, 14 | {file = "black-23.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32daa9783106c28815d05b724238e30718f34155653d4d6e125dc7daec8e260c"}, 15 | {file = "black-23.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:35d1381d7a22cc5b2be2f72c7dfdae4072a3336060635718cc7e1ede24221d6c"}, 16 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:a8a968125d0a6a404842fa1bf0b349a568634f856aa08ffaff40ae0dfa52e7c6"}, 17 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c7ab5790333c448903c4b721b59c0d80b11fe5e9803d8703e84dcb8da56fec1b"}, 18 | {file = "black-23.3.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:a6f6886c9869d4daae2d1715ce34a19bbc4b95006d20ed785ca00fa03cba312d"}, 19 | {file = "black-23.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3c333ea1dd6771b2d3777482429864f8e258899f6ff05826c3a4fcc5ce3f70"}, 20 | {file = "black-23.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:11c410f71b876f961d1de77b9699ad19f939094c3a677323f43d7a29855fe326"}, 21 | {file = "black-23.3.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:1d06691f1eb8de91cd1b322f21e3bfc9efe0c7ca1f0e1eb1db44ea367dff656b"}, 22 | {file = "black-23.3.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cb33cac881766a5cd9913e10ff75b1e8eb71babf4c7104f2e9c52da1fb7de2"}, 23 | {file = "black-23.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:e114420bf26b90d4b9daa597351337762b63039752bdf72bf361364c1aa05925"}, 24 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:48f9d345675bb7fbc3dd85821b12487e1b9a75242028adad0333ce36ed2a6d27"}, 25 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:714290490c18fb0126baa0fca0a54ee795f7502b44177e1ce7624ba1c00f2331"}, 26 | {file = "black-23.3.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:064101748afa12ad2291c2b91c960be28b817c0c7eaa35bec09cc63aa56493c5"}, 27 | {file = "black-23.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:562bd3a70495facf56814293149e51aa1be9931567474993c7942ff7d3533961"}, 28 | {file = "black-23.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:e198cf27888ad6f4ff331ca1c48ffc038848ea9f031a3b40ba36aced7e22f2c8"}, 29 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:3238f2aacf827d18d26db07524e44741233ae09a584273aa059066d644ca7b30"}, 30 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:f0bd2f4a58d6666500542b26354978218a9babcdc972722f4bf90779524515f3"}, 31 | {file = "black-23.3.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:92c543f6854c28a3c7f39f4d9b7694f9a6eb9d3c5e2ece488c327b6e7ea9b266"}, 32 | {file = "black-23.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a150542a204124ed00683f0db1f5cf1c2aaaa9cc3495b7a3b5976fb136090ab"}, 33 | {file = "black-23.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:6b39abdfb402002b8a7d030ccc85cf5afff64ee90fa4c5aebc531e3ad0175ddb"}, 34 | {file = "black-23.3.0-py3-none-any.whl", hash = "sha256:ec751418022185b0c1bb7d7736e6933d40bbb14c14a0abcf9123d1b159f98dd4"}, 35 | {file = "black-23.3.0.tar.gz", hash = "sha256:1c7b8d606e728a41ea1ccbd7264677e494e87cf630e399262ced92d4a8dac940"}, 36 | ] 37 | 38 | [package.dependencies] 39 | click = ">=8.0.0" 40 | mypy-extensions = ">=0.4.3" 41 | packaging = ">=22.0" 42 | pathspec = ">=0.9.0" 43 | platformdirs = ">=2" 44 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 45 | typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} 46 | 47 | [package.extras] 48 | colorama = ["colorama (>=0.4.3)"] 49 | d = ["aiohttp (>=3.7.4)"] 50 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 51 | uvloop = ["uvloop (>=0.15.2)"] 52 | 53 | [[package]] 54 | name = "cfgv" 55 | version = "3.3.1" 56 | description = "Validate configuration and produce human readable error messages." 57 | category = "dev" 58 | optional = false 59 | python-versions = ">=3.6.1" 60 | files = [ 61 | {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, 62 | {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, 63 | ] 64 | 65 | [[package]] 66 | name = "click" 67 | version = "8.1.3" 68 | description = "Composable command line interface toolkit" 69 | category = "dev" 70 | optional = false 71 | python-versions = ">=3.7" 72 | files = [ 73 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 74 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 75 | ] 76 | 77 | [package.dependencies] 78 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 79 | 80 | [[package]] 81 | name = "colorama" 82 | version = "0.4.6" 83 | description = "Cross-platform colored terminal text." 84 | category = "main" 85 | optional = false 86 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 87 | files = [ 88 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 89 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 90 | ] 91 | 92 | [[package]] 93 | name = "distlib" 94 | version = "0.3.6" 95 | description = "Distribution utilities" 96 | category = "dev" 97 | optional = false 98 | python-versions = "*" 99 | files = [ 100 | {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, 101 | {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, 102 | ] 103 | 104 | [[package]] 105 | name = "exceptiongroup" 106 | version = "1.1.1" 107 | description = "Backport of PEP 654 (exception groups)" 108 | category = "dev" 109 | optional = false 110 | python-versions = ">=3.7" 111 | files = [ 112 | {file = "exceptiongroup-1.1.1-py3-none-any.whl", hash = "sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e"}, 113 | {file = "exceptiongroup-1.1.1.tar.gz", hash = "sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785"}, 114 | ] 115 | 116 | [package.extras] 117 | test = ["pytest (>=6)"] 118 | 119 | [[package]] 120 | name = "filelock" 121 | version = "3.12.0" 122 | description = "A platform independent file lock." 123 | category = "dev" 124 | optional = false 125 | python-versions = ">=3.7" 126 | files = [ 127 | {file = "filelock-3.12.0-py3-none-any.whl", hash = "sha256:ad98852315c2ab702aeb628412cbf7e95b7ce8c3bf9565670b4eaecf1db370a9"}, 128 | {file = "filelock-3.12.0.tar.gz", hash = "sha256:fc03ae43288c013d2ea83c8597001b1129db351aad9c57fe2409327916b8e718"}, 129 | ] 130 | 131 | [package.extras] 132 | docs = ["furo (>=2023.3.27)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] 133 | testing = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] 134 | 135 | [[package]] 136 | name = "identify" 137 | version = "2.5.22" 138 | description = "File identification library for Python" 139 | category = "dev" 140 | optional = false 141 | python-versions = ">=3.7" 142 | files = [ 143 | {file = "identify-2.5.22-py2.py3-none-any.whl", hash = "sha256:f0faad595a4687053669c112004178149f6c326db71ee999ae4636685753ad2f"}, 144 | {file = "identify-2.5.22.tar.gz", hash = "sha256:f7a93d6cf98e29bd07663c60728e7a4057615068d7a639d132dc883b2d54d31e"}, 145 | ] 146 | 147 | [package.extras] 148 | license = ["ukkonen"] 149 | 150 | [[package]] 151 | name = "iniconfig" 152 | version = "2.0.0" 153 | description = "brain-dead simple config-ini parsing" 154 | category = "dev" 155 | optional = false 156 | python-versions = ">=3.7" 157 | files = [ 158 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 159 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 160 | ] 161 | 162 | [[package]] 163 | name = "mypy-extensions" 164 | version = "1.0.0" 165 | description = "Type system extensions for programs checked with the mypy type checker." 166 | category = "dev" 167 | optional = false 168 | python-versions = ">=3.5" 169 | files = [ 170 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 171 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 172 | ] 173 | 174 | [[package]] 175 | name = "nodeenv" 176 | version = "1.7.0" 177 | description = "Node.js virtual environment builder" 178 | category = "dev" 179 | optional = false 180 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 181 | files = [ 182 | {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, 183 | {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, 184 | ] 185 | 186 | [package.dependencies] 187 | setuptools = "*" 188 | 189 | [[package]] 190 | name = "packaging" 191 | version = "23.1" 192 | description = "Core utilities for Python packages" 193 | category = "dev" 194 | optional = false 195 | python-versions = ">=3.7" 196 | files = [ 197 | {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, 198 | {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, 199 | ] 200 | 201 | [[package]] 202 | name = "pathspec" 203 | version = "0.11.1" 204 | description = "Utility library for gitignore style pattern matching of file paths." 205 | category = "dev" 206 | optional = false 207 | python-versions = ">=3.7" 208 | files = [ 209 | {file = "pathspec-0.11.1-py3-none-any.whl", hash = "sha256:d8af70af76652554bd134c22b3e8a1cc46ed7d91edcdd721ef1a0c51a84a5293"}, 210 | {file = "pathspec-0.11.1.tar.gz", hash = "sha256:2798de800fa92780e33acca925945e9a19a133b715067cf165b8866c15a31687"}, 211 | ] 212 | 213 | [[package]] 214 | name = "platformdirs" 215 | version = "3.2.0" 216 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 217 | category = "dev" 218 | optional = false 219 | python-versions = ">=3.7" 220 | files = [ 221 | {file = "platformdirs-3.2.0-py3-none-any.whl", hash = "sha256:ebe11c0d7a805086e99506aa331612429a72ca7cd52a1f0d277dc4adc20cb10e"}, 222 | {file = "platformdirs-3.2.0.tar.gz", hash = "sha256:d5b638ca397f25f979350ff789db335903d7ea010ab28903f57b27e1b16c2b08"}, 223 | ] 224 | 225 | [package.extras] 226 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 227 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.2.2)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 228 | 229 | [[package]] 230 | name = "pluggy" 231 | version = "1.0.0" 232 | description = "plugin and hook calling mechanisms for python" 233 | category = "dev" 234 | optional = false 235 | python-versions = ">=3.6" 236 | files = [ 237 | {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, 238 | {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, 239 | ] 240 | 241 | [package.extras] 242 | dev = ["pre-commit", "tox"] 243 | testing = ["pytest", "pytest-benchmark"] 244 | 245 | [[package]] 246 | name = "pre-commit" 247 | version = "3.2.2" 248 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 249 | category = "dev" 250 | optional = false 251 | python-versions = ">=3.8" 252 | files = [ 253 | {file = "pre_commit-3.2.2-py2.py3-none-any.whl", hash = "sha256:0b4210aea813fe81144e87c5a291f09ea66f199f367fa1df41b55e1d26e1e2b4"}, 254 | {file = "pre_commit-3.2.2.tar.gz", hash = "sha256:5b808fcbda4afbccf6d6633a56663fed35b6c2bc08096fd3d47ce197ac351d9d"}, 255 | ] 256 | 257 | [package.dependencies] 258 | cfgv = ">=2.0.0" 259 | identify = ">=1.0.0" 260 | nodeenv = ">=0.11.1" 261 | pyyaml = ">=5.1" 262 | virtualenv = ">=20.10.0" 263 | 264 | [[package]] 265 | name = "pypng" 266 | version = "0.20220715.0" 267 | description = "Pure Python library for saving and loading PNG images" 268 | category = "main" 269 | optional = false 270 | python-versions = "*" 271 | files = [ 272 | {file = "pypng-0.20220715.0-py3-none-any.whl", hash = "sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c"}, 273 | {file = "pypng-0.20220715.0.tar.gz", hash = "sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1"}, 274 | ] 275 | 276 | [[package]] 277 | name = "pytest" 278 | version = "7.3.1" 279 | description = "pytest: simple powerful testing with Python" 280 | category = "dev" 281 | optional = false 282 | python-versions = ">=3.7" 283 | files = [ 284 | {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, 285 | {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, 286 | ] 287 | 288 | [package.dependencies] 289 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 290 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 291 | iniconfig = "*" 292 | packaging = "*" 293 | pluggy = ">=0.12,<2.0" 294 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 295 | 296 | [package.extras] 297 | testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 298 | 299 | [[package]] 300 | name = "pytest-github-actions-annotate-failures" 301 | version = "0.1.8" 302 | description = "pytest plugin to annotate failed tests with a workflow command for GitHub Actions" 303 | category = "dev" 304 | optional = false 305 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" 306 | files = [ 307 | {file = "pytest-github-actions-annotate-failures-0.1.8.tar.gz", hash = "sha256:2d6e6cb5f8d0aae4a27a20cc4e20fabd3199a121c57f44bc48fe28e372e0be23"}, 308 | {file = "pytest_github_actions_annotate_failures-0.1.8-py2.py3-none-any.whl", hash = "sha256:6a882ff21672fa79deae8d917eb965a6bde2b25191e7632e1adfc23ffac008ab"}, 309 | ] 310 | 311 | [package.dependencies] 312 | pytest = ">=4.0.0" 313 | 314 | [[package]] 315 | name = "pyyaml" 316 | version = "6.0" 317 | description = "YAML parser and emitter for Python" 318 | category = "dev" 319 | optional = false 320 | python-versions = ">=3.6" 321 | files = [ 322 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 323 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 324 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 325 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 326 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, 327 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 328 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 329 | {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, 330 | {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, 331 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, 332 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, 333 | {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, 334 | {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, 335 | {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, 336 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 337 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 338 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 339 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, 340 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 341 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 342 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 343 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 344 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 345 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, 346 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 347 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 348 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 349 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 350 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 351 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, 352 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 353 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 354 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 355 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 356 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 357 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 358 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, 359 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 360 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 361 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 362 | ] 363 | 364 | [[package]] 365 | name = "qrcode" 366 | version = "7.4.2" 367 | description = "QR Code image generator" 368 | category = "main" 369 | optional = false 370 | python-versions = ">=3.7" 371 | files = [ 372 | {file = "qrcode-7.4.2-py3-none-any.whl", hash = "sha256:581dca7a029bcb2deef5d01068e39093e80ef00b4a61098a2182eac59d01643a"}, 373 | {file = "qrcode-7.4.2.tar.gz", hash = "sha256:9dd969454827e127dbd93696b20747239e6d540e082937c90f14ac95b30f5845"}, 374 | ] 375 | 376 | [package.dependencies] 377 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 378 | pypng = "*" 379 | typing-extensions = "*" 380 | 381 | [package.extras] 382 | all = ["pillow (>=9.1.0)", "pytest", "pytest-cov", "tox", "zest.releaser[recommended]"] 383 | dev = ["pytest", "pytest-cov", "tox"] 384 | maintainer = ["zest.releaser[recommended]"] 385 | pil = ["pillow (>=9.1.0)"] 386 | test = ["coverage", "pytest"] 387 | 388 | [[package]] 389 | name = "ruff" 390 | version = "0.0.261" 391 | description = "An extremely fast Python linter, written in Rust." 392 | category = "dev" 393 | optional = false 394 | python-versions = ">=3.7" 395 | files = [ 396 | {file = "ruff-0.0.261-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:6624a966c4a21110cee6780333e2216522a831364896f3d98f13120936eff40a"}, 397 | {file = "ruff-0.0.261-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:2dba68a9e558ab33e6dd5d280af798a2d9d3c80c913ad9c8b8e97d7b287f1cc9"}, 398 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8dbd0cee5a81b0785dc0feeb2640c1e31abe93f0d77c5233507ac59731a626f1"}, 399 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:581e64fa1518df495ca890a605ee65065101a86db56b6858f848bade69fc6489"}, 400 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc970f6ece0b4950e419f0252895ee42e9e8e5689c6494d18f5dc2c6ebb7f798"}, 401 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8fa98e747e0fe185d65a40b0ea13f55c492f3b5f9a032a1097e82edaddb9e52e"}, 402 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f268d52a71bf410aa45c232870c17049df322a7d20e871cfe622c9fc784aab7b"}, 403 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1293acc64eba16a11109678dc4743df08c207ed2edbeaf38b3e10eb2597321b"}, 404 | {file = "ruff-0.0.261-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d95596e2f4cafead19a6d1ec0b86f8fda45ba66fe934de3956d71146a87959b3"}, 405 | {file = "ruff-0.0.261-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4bcec45abdf65c1328a269cf6cc193f7ff85b777fa2865c64cf2c96b80148a2c"}, 406 | {file = "ruff-0.0.261-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6c5f397ec0af42a434ad4b6f86565027406c5d0d0ebeea0d5b3f90c4bf55bc82"}, 407 | {file = "ruff-0.0.261-py3-none-musllinux_1_2_i686.whl", hash = "sha256:39abd02342cec0c131b2ddcaace08b2eae9700cab3ca7dba64ae5fd4f4881bd0"}, 408 | {file = "ruff-0.0.261-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:aaa4f52a6e513f8daa450dac4859e80390d947052f592f0d8e796baab24df2fc"}, 409 | {file = "ruff-0.0.261-py3-none-win32.whl", hash = "sha256:daff64b4e86e42ce69e6367d63aab9562fc213cd4db0e146859df8abc283dba0"}, 410 | {file = "ruff-0.0.261-py3-none-win_amd64.whl", hash = "sha256:0fbc689c23609edda36169c8708bb91bab111d8f44cb4a88330541757770ab30"}, 411 | {file = "ruff-0.0.261-py3-none-win_arm64.whl", hash = "sha256:d2eddc60ae75fc87f8bb8fd6e8d5339cf884cd6de81e82a50287424309c187ba"}, 412 | {file = "ruff-0.0.261.tar.gz", hash = "sha256:c1c715b0d1e18f9c509d7c411ca61da3543a4aa459325b1b1e52b8301d65c6d2"}, 413 | ] 414 | 415 | [[package]] 416 | name = "setuptools" 417 | version = "67.7.1" 418 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 419 | category = "dev" 420 | optional = false 421 | python-versions = ">=3.7" 422 | files = [ 423 | {file = "setuptools-67.7.1-py3-none-any.whl", hash = "sha256:6f0839fbdb7e3cfef1fc38d7954f5c1c26bf4eebb155a55c9bf8faf997b9fb67"}, 424 | {file = "setuptools-67.7.1.tar.gz", hash = "sha256:bb16732e8eb928922eabaa022f881ae2b7cdcfaf9993ef1f5e841a96d32b8e0c"}, 425 | ] 426 | 427 | [package.extras] 428 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 429 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 430 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 431 | 432 | [[package]] 433 | name = "tomli" 434 | version = "2.0.1" 435 | description = "A lil' TOML parser" 436 | category = "dev" 437 | optional = false 438 | python-versions = ">=3.7" 439 | files = [ 440 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 441 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 442 | ] 443 | 444 | [[package]] 445 | name = "typing-extensions" 446 | version = "4.5.0" 447 | description = "Backported and Experimental Type Hints for Python 3.7+" 448 | category = "main" 449 | optional = false 450 | python-versions = ">=3.7" 451 | files = [ 452 | {file = "typing_extensions-4.5.0-py3-none-any.whl", hash = "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4"}, 453 | {file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"}, 454 | ] 455 | 456 | [[package]] 457 | name = "virtualenv" 458 | version = "20.22.0" 459 | description = "Virtual Python Environment builder" 460 | category = "dev" 461 | optional = false 462 | python-versions = ">=3.7" 463 | files = [ 464 | {file = "virtualenv-20.22.0-py3-none-any.whl", hash = "sha256:48fd3b907b5149c5aab7c23d9790bea4cac6bc6b150af8635febc4cfeab1275a"}, 465 | {file = "virtualenv-20.22.0.tar.gz", hash = "sha256:278753c47aaef1a0f14e6db8a4c5e1e040e90aea654d0fc1dc7e0d8a42616cc3"}, 466 | ] 467 | 468 | [package.dependencies] 469 | distlib = ">=0.3.6,<1" 470 | filelock = ">=3.11,<4" 471 | platformdirs = ">=3.2,<4" 472 | 473 | [package.extras] 474 | docs = ["furo (>=2023.3.27)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=22.12)"] 475 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.3)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.3.1)", "pytest-env (>=0.8.1)", "pytest-freezegun (>=0.4.2)", "pytest-mock (>=3.10)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)"] 476 | 477 | [metadata] 478 | lock-version = "2.0" 479 | python-versions = "^3.8" 480 | content-hash = "8f83be87a044a2e37239ae98d848534271abd12e805eb46c1e1235fecf071bbf" 481 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "qrplatba" 3 | version = "1.1.1" 4 | description = "QR platba SVG QR code and SPAYD string generator." 5 | authors = ["Viktor Stískala "] 6 | repository = "https://github.com/ViktorStiskala/python-qrplatba" 7 | classifiers=[ 8 | 'Intended Audience :: Developers', 9 | 'Operating System :: OS Independent', 10 | 'Programming Language :: Python', 11 | 'Programming Language :: Python :: 3.8', 12 | 'Programming Language :: Python :: 3.9', 13 | 'Programming Language :: Python :: 3.10', 14 | 'Programming Language :: Python :: 3.11', 15 | 'Topic :: Software Development :: Libraries', 16 | 'Topic :: Software Development :: Libraries :: Python Modules', 17 | ] 18 | license = "MIT" 19 | readme = "README.md" 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.8" 23 | qrcode = "^7.4" 24 | 25 | [tool.poetry.group.test] 26 | optional = true 27 | [tool.poetry.group.test.dependencies] 28 | pytest = "^7.2" 29 | pytest-github-actions-annotate-failures = "^0.1.8" 30 | ruff = "^0.0.261" 31 | black = "^23.3.0" 32 | 33 | [tool.poetry.group.dev.dependencies] 34 | pre-commit = "^3.2.2" 35 | 36 | [tool.ruff] 37 | line-length = 120 38 | 39 | [tool.black] 40 | line-length = 120 41 | 42 | [build-system] 43 | requires = ["poetry-core>=1.0.0"] 44 | build-backend = "poetry.core.masonry.api" 45 | -------------------------------------------------------------------------------- /qrplatba/__init__.py: -------------------------------------------------------------------------------- 1 | from .spayd import QRPlatbaGenerator 2 | 3 | __all__ = ["QRPlatbaGenerator"] 4 | -------------------------------------------------------------------------------- /qrplatba/spayd.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, date 2 | import re 3 | import qrcode 4 | from qrplatba.svg import QRPlatbaSVGImage 5 | 6 | 7 | class QRPlatbaGenerator: 8 | RE_ACCOUNT = re.compile(r"((?P\d+(?=-))-)?(?P\d+)/(?P\d{4})") 9 | 10 | def __init__( 11 | self, 12 | account, 13 | amount=None, 14 | currency=None, 15 | x_vs=None, 16 | x_ss=None, 17 | x_ks=None, 18 | alternate_accounts=None, 19 | recipient_name=None, 20 | due_date=None, 21 | payment_type=None, 22 | message=None, 23 | notification_type=None, 24 | notification_address=None, 25 | x_per=None, 26 | x_id=None, 27 | x_url=None, 28 | reference=None, 29 | ): 30 | """ 31 | http://qr-platba.cz/pro-vyvojare/specifikace-formatu/ 32 | 33 | :param account: ACC account number, can be specified either as IBAN or in CZ format 12-123456789/0300 34 | :param amount: AM payment amount 35 | :param currency: CC currency (3 digits) 36 | :param x_vs: X-VS 37 | :param x_ss: X-SS 38 | :param x_ks: X-KS 39 | :param alternate_accounts: ALT-ACC 40 | :param recipient_name: RN recipient name 41 | :param due_date: DT due date, IS0 8601 42 | :param payment_type: PT max 3 digits 43 | :param message: MSG message for recipient, max 60 chars 44 | :param notification_type: NT P for phone or E for email 45 | :param notification_address: NTA 46 | :param x_per: X-PER number of days to repeat payment if unsuccessful 47 | :param x_id: X-ID 48 | :param x_url: X-URL 49 | :param reference: RF recipient reference number. Max 16 digits. integer. 50 | """ 51 | self.account = account 52 | self.amount = amount 53 | self.currency = currency 54 | self.x_vs = x_vs 55 | self.x_ss = x_ss 56 | self.x_ks = x_ks 57 | self.alternate_accounts = alternate_accounts 58 | self.recipient_name = recipient_name 59 | self.due_date = due_date 60 | self.payment_type = payment_type 61 | self.message = message 62 | self.notification_type = notification_type 63 | self.notification_address = notification_address 64 | self.x_per = x_per 65 | self.x_id = x_id 66 | self.x_url = x_url 67 | self.reference = reference 68 | 69 | def _convert_to_iban(self, account): 70 | """ 71 | Convert czech account number to IBAN 72 | """ 73 | acc = self.RE_ACCOUNT.match(account) 74 | iban = "CZ00{b}{ba:0>6}{a:0>10}".format( 75 | ba=acc.group("ba") or 0, 76 | a=acc.group("a"), 77 | b=acc.group("b"), 78 | ) 79 | 80 | # convert IBAN letters into numbers 81 | crc = re.sub(r"[A-Z]", lambda m: str(ord(m.group(0)) - 55), iban[4:] + iban[:4]) 82 | 83 | # compute control digits 84 | digits = "{:0>2}".format(98 - int(crc) % 97) 85 | 86 | return iban[:2] + digits + iban[4:] 87 | 88 | @property 89 | def _account(self): 90 | if self.account is not None: 91 | out = "ACC:{}*" 92 | 93 | if self.RE_ACCOUNT.match(self.account): 94 | return out.format(self._convert_to_iban(self.account)) 95 | return out.format(self.account) 96 | return "" 97 | 98 | @property 99 | def _alternate_accounts(self): 100 | if self.alternate_accounts is not None: 101 | formatted = [] 102 | for account in self.alternate_accounts: 103 | if self.RE_ACCOUNT.match(account): 104 | formatted.append(self._convert_to_iban(account)) 105 | else: 106 | formatted.append(account) 107 | return "ALT-ACC:{}*".format(",".join(formatted)) 108 | return "" 109 | 110 | @property 111 | def _amount(self): 112 | if self.amount is not None: 113 | return "AM:{:.2f}*".format(self.amount) 114 | return "" 115 | 116 | @property 117 | def _due_date(self): 118 | if self.due_date is not None: 119 | str_part = "DT:{}*" 120 | if isinstance(self.due_date, datetime): 121 | return str_part.format(self.due_date.date().isoformat()).replace("-", "") 122 | if isinstance(self.due_date, date): 123 | return str_part.format(self.due_date.isoformat()).replace("-", "") 124 | return str_part.format(self.due_date) 125 | return "" 126 | 127 | def _format_item_string(self, item, name): 128 | if item: 129 | return "{name}:{value}*".format(name=name, value=item) 130 | return "" 131 | 132 | def get_text(self): 133 | return "SPD*1.0*{ACC}{ALTACC}{AM}{CC}{RF}{RN}{DT}{PT}{MSG}{NT}{NTA}{XPER}{XVS}{XSS}{XKS}{XID}{XURL}".format( 134 | ACC=self._account, 135 | ALTACC=self._alternate_accounts, 136 | AM=self._amount, 137 | CC=self._format_item_string(self.currency, "CC"), 138 | RF=self._format_item_string(self.reference, "RF"), 139 | RN=self._format_item_string(self.recipient_name, "RN"), 140 | DT=self._due_date, 141 | PT=self._format_item_string(self.payment_type, "PT"), 142 | MSG=self._format_item_string(self.message, "MSG"), 143 | NT=self._format_item_string(self.notification_type, "NT"), 144 | NTA=self._format_item_string(self.notification_address, "NTA"), 145 | XPER=self._format_item_string(self.x_per, "X-PER"), 146 | XVS=self._format_item_string(self.x_vs, "X-VS"), 147 | XSS=self._format_item_string(self.x_ss, "X-SS"), 148 | XKS=self._format_item_string(self.x_ks, "X-KS"), 149 | XID=self._format_item_string(self.x_id, "X-ID"), 150 | XURL=self._format_item_string(self.x_url, "X-URL"), 151 | ).rstrip("*") 152 | 153 | def make_image(self, border=2, box_size=12, error_correction=qrcode.constants.ERROR_CORRECT_M): 154 | qr = qrcode.QRCode( 155 | version=None, 156 | error_correction=error_correction, 157 | image_factory=QRPlatbaSVGImage, 158 | border=border, 159 | box_size=box_size, 160 | ) 161 | qr.add_data(self.get_text()) 162 | qr.make(fit=True) 163 | 164 | return qr.make_image() 165 | -------------------------------------------------------------------------------- /qrplatba/svg.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from typing import NamedTuple 3 | 4 | from qrcode.image import svg 5 | from qrcode.compat.etree import ET 6 | 7 | 8 | class ScaledSizes(NamedTuple): 9 | inside_border: Decimal 10 | outside_border: Decimal 11 | width: Decimal 12 | line_size: Decimal 13 | ratio: Decimal 14 | 15 | 16 | class QRPlatbaSVGImage(svg.SvgPathImage): 17 | """ 18 | QR Platba SVG image generator. 19 | 20 | Inner padding is created according to specification (http://qr-platba.cz/pro-vyvojare/specifikace-formatu/), 21 | text size is computed to achieve width of 16 QR points. 22 | """ 23 | 24 | QR_TEXT_STYLE = "font-size:{size}px;font-weight:bold;fill:#000000;font-family:Arial;" 25 | FONT_SIZE = Decimal("3.5") 26 | FONT_HEIGHT = Decimal("8") 27 | 28 | LINE_SIZE = Decimal("0.25") 29 | INSIDE_BORDER = 4 30 | 31 | BOTTOM_LINE_SEGMENTS = (2, 22) 32 | 33 | def __init__(self, border, width, box_size, *args, **kwargs): 34 | self.outside_border = border 35 | border += self.INSIDE_BORDER + self.LINE_SIZE # outside border + inside border + line size 36 | 37 | super().__init__(border, width, box_size, *args, **kwargs) 38 | 39 | def _get_scaled_sizes(self): 40 | """Computes sizes of the QR code and QR text according to the scale ratio""" 41 | scale_ratio = self.units(self.box_size, text=False) 42 | 43 | def strip_zeros(value): 44 | return Decimal(str(value).rstrip("0").rstrip(".")) if "." in str(value) else value 45 | 46 | return ScaledSizes( 47 | inside_border=strip_zeros(self.INSIDE_BORDER * scale_ratio), 48 | outside_border=strip_zeros(self.outside_border * scale_ratio), 49 | width=strip_zeros(self.width * scale_ratio), 50 | line_size=strip_zeros(self.LINE_SIZE * scale_ratio), 51 | ratio=scale_ratio, 52 | ) 53 | 54 | def make_border(self): 55 | """Creates black thin border around QR code""" 56 | scaled = self._get_scaled_sizes() 57 | 58 | def sizes(ob, ib, wd, ln): # size helper 59 | return ob * scaled.outside_border + ib * scaled.inside_border + wd * scaled.width + ln * scaled.line_size 60 | 61 | horizontal_line = "M{x0},{y0}h{length}v{width}h-{length}z" 62 | vertical_line = "M{x0},{y0}v{length}h{width}v-{length}z" 63 | 64 | def get_subpaths(): 65 | # top line 66 | yield horizontal_line.format( 67 | x0=scaled.outside_border, 68 | y0=scaled.outside_border, 69 | length=sizes(0, 2, 1, 2), 70 | width=scaled.line_size, 71 | ) 72 | 73 | b_first, b_second = self.BOTTOM_LINE_SEGMENTS 74 | 75 | # bottom line - first segment 76 | yield horizontal_line.format( 77 | x0=scaled.outside_border, 78 | y0=sizes(1, 2, 1, 1), 79 | length=b_first * scaled.ratio, 80 | width=scaled.line_size, 81 | ) 82 | 83 | # bottom line - second segment 84 | yield horizontal_line.format( 85 | x0=scaled.outside_border + b_second * scaled.ratio, 86 | y0=sizes(1, 2, 1, 1), 87 | length=sizes(0, 2, 1, 2) - b_second * scaled.ratio, 88 | width=scaled.line_size, 89 | ) 90 | 91 | # left line 92 | yield vertical_line.format( 93 | x0=scaled.outside_border, 94 | y0=scaled.outside_border + scaled.line_size, 95 | length=scaled.width + 2 * scaled.inside_border, 96 | width=scaled.line_size, 97 | ) 98 | 99 | # right line 100 | yield vertical_line.format( 101 | x0=sizes(1, 2, 1, 1), 102 | y0=sizes(1, 0, 0, 1), 103 | length=sizes(0, 2, 1, 0), 104 | width=scaled.line_size, 105 | ) 106 | 107 | subpaths = " ".join(get_subpaths()) 108 | return ET.Element("path", d=subpaths, id="qrplatba-border", **self.QR_PATH_STYLE) 109 | 110 | def make_text(self): 111 | """Creates "QR platba" text element""" 112 | scaled = self._get_scaled_sizes() 113 | text_style = self.QR_TEXT_STYLE.format(size=(self.FONT_SIZE * scaled.ratio).quantize(Decimal("0.01"))) 114 | 115 | x_pos = str(scaled.outside_border + scaled.line_size + 4 * scaled.ratio) 116 | y_pos = str( 117 | scaled.outside_border 118 | + scaled.line_size 119 | + 2 * scaled.inside_border 120 | + scaled.width 121 | + (self.FONT_HEIGHT / 4) * scaled.ratio 122 | ) 123 | 124 | text_el = ET.Element("text", style=text_style, x=x_pos, y=y_pos, id="qrplatba-text") 125 | text_el.text = "QR platba" 126 | 127 | return text_el 128 | 129 | def _svg(self, viewBox=None, **kwargs): 130 | scaled = self._get_scaled_sizes() 131 | h_pixels = self.pixel_size + (self.FONT_HEIGHT * scaled.ratio) 132 | 133 | box = "0 0 {w} {h}".format( 134 | w=self.units(self.pixel_size, text=False), 135 | h=self.units(h_pixels, text=False), 136 | ) 137 | svg_el = super()._svg(viewBox=box, **kwargs) 138 | svg_el.append(self.make_border()) 139 | svg_el.append(self.make_text()) 140 | 141 | # update size of the SVG element 142 | svg_el.attrib["height"] = str(self.units(h_pixels)) 143 | 144 | return svg_el 145 | -------------------------------------------------------------------------------- /tests/test_svg.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | from qrplatba import QRPlatbaGenerator 4 | from datetime import datetime, timedelta 5 | import xml.etree.ElementTree as ET 6 | 7 | 8 | class TestSVGImage: 9 | data = { 10 | "account": "123456789/0123", 11 | "amount": 400.56, 12 | "x_vs": 2034456, 13 | "message": "text", 14 | "due_date": datetime.now() + timedelta(days=14), 15 | } 16 | 17 | def make_image(self, **kwargs): 18 | generator = QRPlatbaGenerator(**self.data) 19 | img = generator.make_image(**kwargs) 20 | 21 | return img.to_string(encoding="unicode") 22 | 23 | def test_svg_content(self): 24 | svg_data = self.make_image() 25 | 26 | assert "http://www.w3.org/2000/svg" in svg_data 27 | 28 | root = ET.fromstring(svg_data) 29 | text = root.find(".//{http://www.w3.org/2000/svg}text") 30 | assert text is not None 31 | assert text.text == "QR platba" 32 | 33 | paths = root.findall(".//{http://www.w3.org/2000/svg}path") 34 | assert len(paths) == 2 35 | 36 | for path in paths: 37 | assert path.get("id") is not None 38 | assert path.get("id") in ("qr-path", "qrplatba-border") 39 | 40 | def test_svg_scaling(self): 41 | svg_data = self.make_image(box_size=40) 42 | 43 | root = ET.fromstring(svg_data) 44 | 45 | assert root is not None 46 | assert root.get("width") == "198mm" 47 | assert root.get("height") == "201.2mm" 48 | 49 | def test_file_save(self, tmp_path): 50 | generator = QRPlatbaGenerator(**self.data) 51 | img = generator.make_image() 52 | 53 | filename = tmp_path / "example.svg" 54 | 55 | img.save(filename) 56 | assert filename.exists() 57 | 58 | root = ET.parse(filename).getroot() 59 | assert root is not None 60 | viewbox = root.get("viewBox") 61 | assert viewbox is not None 62 | 63 | data = map(Decimal, viewbox.split(" ")) 64 | assert list(data) == [Decimal(0), Decimal(0), Decimal("59.4"), Decimal("60.36")] 65 | --------------------------------------------------------------------------------