├── tests ├── __init__.py ├── data │ ├── qr_version_001.png │ ├── qr_version_002.png │ ├── template.yaml │ ├── qr_version_002.txt │ └── qr_version_001.txt ├── template_version_002.yaml ├── template_version_002_broken.yaml ├── test_cli.py └── test_py_epc_qr.py ├── codecov.yml ├── py_epc_qr ├── __init__.py ├── __main__.py ├── constants.py ├── cli.py ├── checks.py └── transaction.py ├── img ├── create_qr.gif └── qr_wikimedia.png ├── template.yaml ├── .github └── workflows │ ├── black.yml │ └── pytest.yml ├── pyproject.toml ├── license.md ├── .gitignore ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Init""" 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "*/tests/*” -------------------------------------------------------------------------------- /py_epc_qr/__init__.py: -------------------------------------------------------------------------------- 1 | """Init""" 2 | __version__ = "0.1.3" 3 | -------------------------------------------------------------------------------- /img/create_qr.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timueh/py-epc-qr/HEAD/img/create_qr.gif -------------------------------------------------------------------------------- /img/qr_wikimedia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timueh/py-epc-qr/HEAD/img/qr_wikimedia.png -------------------------------------------------------------------------------- /tests/data/qr_version_001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timueh/py-epc-qr/HEAD/tests/data/qr_version_001.png -------------------------------------------------------------------------------- /tests/data/qr_version_002.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timueh/py-epc-qr/HEAD/tests/data/qr_version_002.png -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | beneficiary: Wikimedia Fördergesellschaft 2 | iban: DE33100205000001194700 3 | amount: 10 4 | remittance: Danke -------------------------------------------------------------------------------- /tests/data/template.yaml: -------------------------------------------------------------------------------- 1 | beneficiary: Wikimedia Fördergesellschaft 2 | iban: DE33100205000001194700 3 | amount: 10 4 | remittance: Danke -------------------------------------------------------------------------------- /tests/template_version_002.yaml: -------------------------------------------------------------------------------- 1 | beneficiary: Wikimedia Fördergesellschaft 2 | iban: DE33100205000001194700 3 | amount: 10 4 | remittance: Danke -------------------------------------------------------------------------------- /tests/template_version_002_broken.yaml: -------------------------------------------------------------------------------- 1 | beneficiaries: Wikimedia Fördergesellschaft 2 | iban: DE33100205000001194700 3 | amount: 10 4 | remittance: Danke -------------------------------------------------------------------------------- /py_epc_qr/__main__.py: -------------------------------------------------------------------------------- 1 | from py_epc_qr import cli 2 | 3 | 4 | def main(): 5 | """main""" 6 | cli.app() 7 | 8 | 9 | if __name__ == "__main__": 10 | main() 11 | -------------------------------------------------------------------------------- /tests/data/qr_version_002.txt: -------------------------------------------------------------------------------- 1 | BCD 2 | 002 3 | 1 4 | SCT 5 | 6 | Wikimedia Foerdergesellschaft 7 | DE33100205000001194700 8 | EUR123.45 9 | 10 | 11 | Spende fuer Wikipedia 12 | -------------------------------------------------------------------------------- /tests/data/qr_version_001.txt: -------------------------------------------------------------------------------- 1 | BCD 2 | 001 3 | 1 4 | SCT 5 | BFSWDE33BER 6 | Wikimedia Foerdergesellschaft 7 | DE33100205000001194700 8 | EUR123.45 9 | 10 | 11 | Spende fuer Wikipedia 12 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint with Black 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: psf/black@stable 11 | with: 12 | options: "--check --verbose" 13 | src: "." -------------------------------------------------------------------------------- /py_epc_qr/constants.py: -------------------------------------------------------------------------------- 1 | """Constants according to epc qr code specification.""" 2 | 3 | ALLOWED_KEYS = ["beneficiary", "iban", "amount", "remittance"] 4 | 5 | ROW_MAPPING = { 6 | 1: "bcd", 7 | 2: "version", 8 | 3: "encoding", 9 | 4: "identification_code", 10 | 5: "bic", 11 | 6: "beneficiary", 12 | 7: "iban", 13 | 8: "amount", 14 | 9: "purpose", 15 | 10: "remittance_structured", 16 | 11: "remittance_unstructured", 17 | 12: "originator_information", 18 | } 19 | 20 | ENCODINGS = { 21 | 1: "UTF-8", 22 | 2: "ISO-8859-1", 23 | 3: "ISO-8859-2", 24 | 4: "ISO-8859-4", 25 | 5: "ISO-8859-5", 26 | 6: "ISO-8859-7", 27 | 7: "ISO-8859-10", 28 | 8: "ISO-8859-15", 29 | } 30 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "py-epc-qr" 3 | version = "0.1.3" 4 | description = "Generate EPC-compatible QR codes for wire transfers" 5 | authors = ["timueh "] 6 | license = "MIT" 7 | readme = "README.md" 8 | homepage = "https://github.com/timueh/py-epc-qr" 9 | repository = "https://github.com/timueh/py-epc-qr" 10 | documentation = "https://github.com/timueh/py-epc-qr" 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.9" 14 | PyYAML = "^6.0" 15 | "qrcode[pil]" = "^7.3.1" # Let qrcode decide of the version of Pillow to be installed 16 | typer = "^0.4.1" 17 | 18 | [tool.poetry.dev-dependencies] 19 | pytest = "^5.2" 20 | 21 | [tool.poetry.scripts] 22 | epcqr = "py_epc_qr.__main__:main" 23 | 24 | [build-system] 25 | requires = ["poetry-core>=1.0.0"] 26 | build-backend = "poetry.core.masonry.api" 27 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Tillmann Mühlpfordt, t.muehlpfordt@mailbox.org 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Tests and flake8 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.9", "3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v3 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | - name: Install dependencies 20 | run: | 21 | python -m pip install --upgrade pip 22 | pip install flake8 pytest poetry pytest-cov 23 | poetry export -f requirements.txt --output requirements.txt 24 | pip install -r requirements.txt 25 | - name: Lint with flake8 26 | run: | 27 | # stop the build if there are Python syntax errors or undefined names 28 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 29 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 30 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 31 | - name: Test with pytest 32 | run: | 33 | pytest tests --doctest-modules --cov=py_epc_qr --cov-report=xml --cov-report=html 34 | - name: Upload coverage to Codecov 35 | uses: codecov/codecov-action@v2 36 | with: 37 | env_vars: OS,PYTHON 38 | fail_ci_if_error: true 39 | files: coverage.xml 40 | flags: unittests 41 | name: codecov-umbrella 42 | verbose: true 43 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the cli. 3 | """ 4 | 5 | import pytest 6 | from typer.testing import CliRunner 7 | 8 | from py_epc_qr import __version__ 9 | from py_epc_qr.cli import app 10 | 11 | runner = CliRunner() 12 | 13 | 14 | def test_app_from_yaml(): 15 | """ 16 | Given a yaml template 17 | When creating the QR code 18 | Then everything works as intended 19 | """ 20 | result = runner.invoke(app, ["create", "--from-yaml", "tests/data/template.yaml"]) 21 | assert result.exit_code == 0 22 | 23 | 24 | def test_app_from_prompt(): 25 | """ 26 | Given a valid prompt input 27 | When creating the QR code 28 | Then everything works as intended 29 | """ 30 | result = runner.invoke( 31 | app, ["create"], input="test\nDE33100205000001194700\n10\nDanke" 32 | ) 33 | assert result.exit_code == 0 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "value, expected", 38 | [ 39 | ("$§\n", 1), 40 | ("hello\nDE\n", 1), 41 | ("hello\nDE33100205000001194700\n-10\n", 1), 42 | ("hello\nDE33100205000001194700\n10\n$$\n", 1), 43 | ], 44 | ) 45 | def test_app_from_prompt_throws_error(value, expected): 46 | """ 47 | Given invalid prompt inputs 48 | When creating the QR code 49 | Then an expected return code is thrown 50 | """ 51 | result = runner.invoke(app, ["create"], input=value) 52 | assert result.exit_code == expected 53 | 54 | 55 | def test_app_version(): 56 | """ 57 | Given the version command 58 | When invoking it 59 | Then the version is printed correctly 60 | """ 61 | result = runner.invoke(app, ["version"]) 62 | assert result.stdout == f"py-epc-qr v{__version__}\n" 63 | assert result.exit_code == 0 64 | -------------------------------------------------------------------------------- /py_epc_qr/cli.py: -------------------------------------------------------------------------------- 1 | """Implements the command line interface (CLI).""" 2 | 3 | import typer 4 | 5 | from py_epc_qr import __version__ 6 | from py_epc_qr.checks import ( 7 | check_amount, 8 | check_beneficiary, 9 | check_iban, 10 | check_remittance_unstructured, 11 | validate_prompt, 12 | ) 13 | from py_epc_qr.transaction import consumer_epc_qr 14 | 15 | app = typer.Typer() 16 | 17 | 18 | @app.command() 19 | def create( 20 | out: str = typer.Option( 21 | default="qr.png", 22 | help="name of generated qr png file", 23 | ), 24 | from_yaml: str = typer.Option( 25 | default="", 26 | help="specify yaml file from which to create qr", 27 | ), 28 | ): 29 | """ 30 | Create EPC-compliant QR code for IBAN-based wire transfer within European economic area. 31 | """ 32 | 33 | if from_yaml: 34 | typer.echo("creating qr code from yaml...") 35 | epc = consumer_epc_qr.from_yaml(from_yaml) 36 | else: 37 | beneficiary = typer.prompt("Enter the beneficiary", type=str) 38 | if not validate_prompt(check_beneficiary(beneficiary)): 39 | typer.echo("The beneficiary is not valid.") 40 | raise typer.Exit(code=1) 41 | iban = typer.prompt("Enter the IBAN", type=str) 42 | if not validate_prompt(check_iban(iban)): 43 | typer.echo("The IBAN appears incorrect.") 44 | raise typer.Exit(code=1) 45 | amount = typer.prompt("Enter the amount", type=float) 46 | if not validate_prompt(check_amount(amount)): 47 | typer.echo("The amount appears incorrect (must be float).") 48 | raise typer.Exit(code=1) 49 | remittance = typer.prompt("Enter reason for payment", type=str) 50 | if not validate_prompt(check_remittance_unstructured(remittance)): 51 | typer.echo("The value for the remittance appears incorrect.") 52 | raise typer.Exit(code=1) 53 | epc = consumer_epc_qr(beneficiary, iban, amount, remittance) 54 | 55 | epc.to_qr(out) 56 | typer.echo(f"🎉🎉🎉 You may view your png {out} 🎉🎉🎉") 57 | 58 | 59 | @app.command() 60 | def version(): 61 | """ 62 | Show version and exit. 63 | """ 64 | typer.echo(f"py-epc-qr v{__version__}") 65 | raise typer.Exit() 66 | 67 | 68 | @app.callback() 69 | def main(): 70 | """ 71 | Create EPC-compliant QR codes for wire transfers. 72 | """ 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | *.png 3 | *.txt 4 | *.coverage 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | *.py,cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | .pybuilder/ 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | # For a library or package, you might want to ignore these files since the code is 92 | # intended to run in multiple environments; otherwise, check them in: 93 | # .python-version 94 | 95 | # pipenv 96 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 97 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 98 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 99 | # install all needed dependencies. 100 | #Pipfile.lock 101 | 102 | # poetry 103 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 104 | # This is especially recommended for binary packages to ensure reproducibility, and is more 105 | # commonly ignored for libraries. 106 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 107 | #poetry.lock 108 | 109 | # pdm 110 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 111 | #pdm.lock 112 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 113 | # in version control. 114 | # https://pdm.fming.dev/#use-with-ide 115 | .pdm.toml 116 | 117 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 118 | __pypackages__/ 119 | 120 | # Celery stuff 121 | celerybeat-schedule 122 | celerybeat.pid 123 | 124 | # SageMath parsed files 125 | *.sage.py 126 | 127 | # Environments 128 | .env 129 | .venv 130 | env/ 131 | venv/ 132 | ENV/ 133 | env.bak/ 134 | venv.bak/ 135 | 136 | # Spyder project settings 137 | .spyderproject 138 | .spyproject 139 | 140 | # Rope project settings 141 | .ropeproject 142 | 143 | # mkdocs documentation 144 | /site 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | -------------------------------------------------------------------------------- /py_epc_qr/checks.py: -------------------------------------------------------------------------------- 1 | """Several functions that validate the epc qr code format.""" 2 | 3 | from collections import namedtuple 4 | 5 | check = namedtuple("Check", ["valid", "error"]) 6 | 7 | 8 | def check_version(value: str, bic: str) -> tuple: 9 | """ 10 | Checks whether the version entry is valid. 11 | Returns a namedtuple of kind `check`, which is either `(True, None)` or `(False, AssertionError)`. 12 | """ 13 | if value not in (valid_versions := ["001", "002"]): 14 | return check( 15 | False, 16 | ValueError(f"invalid version `{value}` (choose from {valid_versions}"), 17 | ) 18 | if value == "001" and not bic: 19 | return check(False, AssertionError("version 001 requires a BIC")) 20 | return check(True, None) 21 | 22 | 23 | def check_amount(value: float) -> tuple: 24 | """ 25 | Checks whether the amount entry is valid. 26 | Returns a namedtuple of kind `check`, which is either `(True, None)` or `(False, AssertionError)`. 27 | """ 28 | try: 29 | value = float(value) 30 | except Exception as e: 31 | return check( 32 | False, 33 | ValueError(f"amount must be convertible to float; exception raised: {e}"), 34 | ) 35 | if not 0.01 <= value <= 999999999.99: 36 | return check(False, ValueError(f"the amount {value} is out of bounds")) 37 | 38 | if value != round(value, 2): 39 | return check(False, ValueError("the amount is not a two-digit decimal number")) 40 | return check(True, None) 41 | 42 | 43 | def check_encoding(value: str) -> tuple: 44 | """ 45 | Checks whether the encoding entry is valid. 46 | Returns a namedtuple of kind `check`, which is either `(True, None)` or `(False, AssertionError)`. 47 | """ 48 | if not 1 <= int(value) <= 8: 49 | return check(False, ValueError("encoding must be between 1 and 8")) 50 | return check(True, None) 51 | 52 | 53 | def check_beneficiary(value: str) -> tuple: 54 | """ 55 | Checks whether the beneficiary entry is valid. 56 | Returns a namedtuple of kind `check`, which is either `(True, None)` or `(False, AssertionError)`. 57 | """ 58 | if not value.replace(" ", "").isalnum(): 59 | return check(False, ValueError("beneficiary is not alphanumeric")) 60 | if not 1 <= len(value) <= (max_length := 70): 61 | return check( 62 | False, 63 | ValueError( 64 | f"beneficiary is mandatory, and must not exceed {max_length} characters" 65 | ), 66 | ) 67 | return check(True, None) 68 | 69 | 70 | def check_iban(value: str) -> tuple: 71 | """ 72 | Checks whether the iban entry is valid. 73 | Returns a namedtuple of kind `check`, which is either `(True, None)` or `(False, AssertionError)`. 74 | """ 75 | if not value.isalnum(): 76 | return check(False, ValueError("iban is not alphanumeric")) 77 | country_code = value[0:1] 78 | check_digits = value[2:3] 79 | bban = value[4:] 80 | if not country_code.isalpha(): 81 | return check(False, ValueError("invalid iban country code")) 82 | if not check_digits.isnumeric(): 83 | return check(False, ValueError("invalid check digits")) 84 | if len(bban) > 30: 85 | return check(False, ValueError("bban is too long")) 86 | return check(True, None) 87 | 88 | 89 | def check_remittance_unstructured(value: str) -> tuple: 90 | """ 91 | Checks whether the unstructured remittance entry is valid. 92 | Returns a namedtuple of kind `check`, which is either `(True, None)` or `(False, AssertionError)`. 93 | """ 94 | if not value.replace(" ", "").isalnum(): 95 | return check(False, ValueError("unstructered remittance is non alphanumeric")) 96 | if len(value) > 140: 97 | return check( 98 | False, ValueError("unstructured remittance exceeds 140 characters") 99 | ) 100 | return check(True, None) 101 | 102 | 103 | def validate(res: namedtuple) -> None: 104 | """ 105 | Raises the error from the `check` namedtuple, if any. 106 | """ 107 | if not res.valid and res.error is not None: 108 | raise res.error 109 | 110 | 111 | def validate_prompt(res: namedtuple) -> None: 112 | """ 113 | Raises the boolean from the `check` namedtuple. 114 | """ 115 | return res.valid 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 2 | [![codecov](https://codecov.io/gh/timueh/py-epc-qr/branch/main/graph/badge.svg?token=LMQKVGWT2W)](https://codecov.io/gh/timueh/py-epc-qr) 3 | ![tests](https://github.com/timueh/py-epc-qr/actions/workflows/pytest.yml/badge.svg) 4 | ![lint_with_black](https://github.com/timueh/py-epc-qr/actions/workflows/black.yml/badge.svg) 5 | [![pypi](https://img.shields.io/badge/PyPI-py--epc--qr-blue)](https://pypi.org/project/py-epc-qr/) 6 | 7 | 8 | # Create QR codes for wire transfers 9 | 10 | Sick of copy-and-pasting IBANs to forms? 11 | Why not just scan a QR code and have your favorite banking app take care of the rest? 12 | 13 | Why not be generous and support wikipedia with EUR10? 14 | Grab your phone, open your banking app, select the QR scanner and scan the image below which was created with this tool. 15 | 16 | ![Support Wikipedia with 10 €](img/create_qr.gif "Support Wikipedia with 10 €") 17 | 18 | ![Support Wikipedia with 10 €](img/qr_wikimedia.png "Support Wikipedia with 10 €") 19 | 20 | [The created QR code complies with the European Payments Council (EPC) Quick Response (QR) code guidelines.](https://en.wikipedia.org/wiki/EPC_QR_code) 21 | 22 | **1st Disclaimer**: The author of this code has no affiliation with the EPC whatsoever. 23 | Henceforth, you are welcome to use the code at your own dispense, but any use is at your own (commercial) risk. 24 | 25 | **2nd Disclaimer**: Currently, the EPC specifications are implemented only to work with IBAN-based consumer wire transfers within the European Economic Area (EEA), i.e. using the following pieces of information: 26 | 27 | - Recipient 28 | - IBAN 29 | - Amount 30 | - Unstructured remittance (aka reason for transfer) 31 | 32 | Of course, any helping hand is welcome to extend the core functionality to more generic transactions. 33 | 34 | ## Installation 35 | 36 | To use the code as a standalone command line interface (CLI) tool, then use [`pipx`](https://pypa.github.io/pipx/) as follows 37 | 38 | ```bash 39 | pipx install py-epc-qr 40 | ``` 41 | 42 | You may verify the installation by calling `epcqr version`. 43 | The output should be identical to what `pipx` printed. 44 | 45 | If you intend to use the code instead directly in your own Python projects, then install the package using `pip` 46 | 47 | ```bash 48 | pip install py-epc-qr 49 | ``` 50 | 51 | 52 | ## Usage 53 | 54 | You may use the package as a standalone command line interface (CLI) or as part of your own code. 55 | 56 | ### CLI 57 | 58 | Having installed the package with `pipx` (see [above](#installation)), you may verify the installation upon calling 59 | 60 | ```bash 61 | >> epcqr --help 62 | Usage: epcqr [OPTIONS] COMMAND [ARGS]... 63 | 64 | Create EPC-compliant QR codes for wire transfers. 65 | 66 | Options: 67 | --install-completion [bash|zsh|fish|powershell|pwsh] 68 | Install completion for the specified shell. 69 | --show-completion [bash|zsh|fish|powershell|pwsh] 70 | Show completion for the specified shell, to 71 | copy it or customize the installation. 72 | --help Show this message and exit. 73 | 74 | Commands: 75 | create Create EPC-compliant QR code for IBAN-based wire transfer... 76 | version Show version and exit. 77 | ``` 78 | 79 | The last lines show the available commands. 80 | 81 | The core functionality lies behind `create`, for which you can call again the `help`. 82 | 83 | ```bash 84 | epcqr create --help 85 | Usage: epcqr create [OPTIONS] 86 | 87 | Create EPC-compliant QR code for IBAN-based wire transfer within European 88 | economic area. 89 | 90 | Options: 91 | --out TEXT name of generated qr png file [default: qr.png] 92 | --from-yaml TEXT specify yaml file from which to create qr 93 | --help Show this message and exit. 94 | ``` 95 | 96 | #### From interaction 97 | 98 | If you call the `create` command without any options, it is started in an interactive mode. 99 | You are asked to input all relevant information. 100 | If your input is correct, an image will be created in your current directory. 101 | 102 | #### From template 103 | 104 | Alternatively, you can create the QR code from a `yaml` template, [for which the repository contains an example](template.yaml). 105 | 106 | ### Code 107 | 108 | If you intend to use the source code in your own Python projects, then a minimal working example looks as follows: 109 | 110 | ```python 111 | from py_epc_qr.transaction import consumer_epc_qr 112 | epc_qr = consumer_epc_qr( 113 | beneficiary= "Wikimedia Foerdergesellschaft", 114 | iban= "DE33100205000001194700", 115 | amount= 123.45, 116 | remittance= "Spende fuer Wikipedia" 117 | ) 118 | epc_qr.to_qr() 119 | ``` 120 | 121 | The relevant functions are gathered in [`transaction.py`](py_epc_qr/transaction.py) 122 | 123 | -------------------------------------------------------------------------------- /py_epc_qr/transaction.py: -------------------------------------------------------------------------------- 1 | """Core functionality that converts epc qr code format to qr code image.""" 2 | 3 | import qrcode 4 | import yaml 5 | 6 | from py_epc_qr.checks import ( 7 | check_amount, 8 | check_beneficiary, 9 | check_encoding, 10 | check_iban, 11 | check_remittance_unstructured, 12 | check_version, 13 | validate, 14 | ) 15 | from py_epc_qr.constants import ALLOWED_KEYS, ENCODINGS, ROW_MAPPING 16 | 17 | 18 | class epc_qr: 19 | """ 20 | Class containing epc qr code specification and its conversion to image/text. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | version: str, 26 | encoding: int, 27 | bic: str, 28 | beneficiary: str, 29 | iban: str, 30 | amount: float, 31 | purpose: str, 32 | remittance_structured: str, 33 | remittance_unstructured: str, 34 | originator_information: str, 35 | ): 36 | """Initialize""" 37 | self.bcd = "BCD" 38 | self.bic = bic 39 | self.version = version 40 | self.encoding = encoding 41 | self.identification_code = "SCT" 42 | self.beneficiary = beneficiary 43 | self.iban = iban 44 | self.amount = amount 45 | self.purpose = purpose 46 | self.remittance_structured = remittance_structured 47 | self.remittance_unstructured = remittance_unstructured 48 | self.originator_information = originator_information 49 | 50 | def to_txt(self, file_name: str = "qr_source.txt") -> None: 51 | """ 52 | Write EPC-compliant string to text file `file_name` 53 | """ 54 | with open(file_name, "w", encoding=self.resolve_encoding()) as file: 55 | for key, value in ROW_MAPPING.items(): 56 | file.write(self.__getattribute__(value)) 57 | if key < 12: 58 | file.write("\n") 59 | 60 | def to_str(self) -> str: 61 | """ 62 | Write EPC-compliant string. 63 | """ 64 | res = "" 65 | for key, value in ROW_MAPPING.items(): 66 | res += self.__getattribute__(value) 67 | if key < 12: 68 | res += "\n" 69 | return res 70 | 71 | def to_qr(self, file_name: str = "qr.png"): 72 | """ 73 | Write EPC-compliant string to png image `file_name` 74 | """ 75 | qr = qrcode.QRCode( 76 | version=6, 77 | error_correction=qrcode.constants.ERROR_CORRECT_M, 78 | ) 79 | qr.add_data(self.to_str()) 80 | qr.make() 81 | img = qr.make_image() 82 | img.save(file_name) 83 | print("created image") 84 | 85 | # Properties of class 86 | 87 | @property 88 | def version(self) -> str: 89 | """ 90 | Return EPC version entry. 91 | """ 92 | return self.__version 93 | 94 | @version.setter 95 | def version(self, value: str): 96 | """ 97 | Set EPC version entry. 98 | """ 99 | validate(check_version(value, self.bic)) 100 | self.__version = value 101 | 102 | @property 103 | def amount(self) -> str: 104 | """ 105 | Return EPC amount entry. 106 | """ 107 | return self.__amount 108 | 109 | @amount.setter 110 | def amount(self, value): 111 | """ 112 | Set and validate EPC amount entry. 113 | """ 114 | validate(check_amount(value)) 115 | self.__amount = "EUR{:.2f}".format(float(value)) 116 | 117 | @property 118 | def encoding(self) -> str: 119 | """ 120 | Return EPC encoding entry. 121 | """ 122 | return self.__encoding 123 | 124 | @encoding.setter 125 | def encoding(self, value) -> None: 126 | """ 127 | Set and validate EPC encoding entry. 128 | """ 129 | validate(check_encoding(value)) 130 | self.__encoding = str(value) 131 | 132 | def resolve_encoding(self) -> str: 133 | """ 134 | Resolve EPC encoding entry. 135 | """ 136 | return ENCODINGS[int(self.encoding)] 137 | 138 | @property 139 | def beneficiary(self) -> str: 140 | """ 141 | Return EPC beneficiary entry. 142 | """ 143 | return self.__beneficiary 144 | 145 | @beneficiary.setter 146 | def beneficiary(self, value: str) -> None: 147 | """ 148 | Set and validate EPC beneficiary entry. 149 | """ 150 | validate(check_beneficiary(value)) 151 | self.__beneficiary = value 152 | 153 | @property 154 | def iban(self) -> str: 155 | """ 156 | Return EPC iban entry. 157 | """ 158 | return self.__iban 159 | 160 | @iban.setter 161 | def iban(self, value: str): 162 | """ 163 | Set and validate EPC iban entry. 164 | """ 165 | validate(check_iban(value)) 166 | self.__iban = value 167 | 168 | @property 169 | def remittance_unstructured(self) -> str: 170 | """ 171 | Return EPC unstructured remittance entry. 172 | """ 173 | return self.__remittance_unstructured 174 | 175 | @remittance_unstructured.setter 176 | def remittance_unstructured(self, value) -> None: 177 | """ 178 | Set and validate EPC unstructured remittance entry. 179 | """ 180 | validate(check_remittance_unstructured(value)) 181 | self.__remittance_unstructured = value 182 | 183 | 184 | class consumer_epc_qr(epc_qr): 185 | """ 186 | Standard consumer EPC QR code for IBAN-based wire transfer within European economic area. 187 | """ 188 | 189 | def __init__(self, beneficiary: str, iban: str, amount: float, remittance: str): 190 | """Initialize""" 191 | super().__init__( 192 | version="002", 193 | encoding=1, 194 | bic="", 195 | beneficiary=beneficiary, 196 | iban=iban, 197 | amount=amount, 198 | purpose="", 199 | remittance_structured="", 200 | remittance_unstructured=remittance, 201 | originator_information="", 202 | ) 203 | 204 | @classmethod 205 | def from_yaml(cls, file_name: str): 206 | """Create from yaml file""" 207 | with open(file_name, "r") as file: 208 | data = yaml.safe_load(file) 209 | if not sorted(list(data.keys())) == sorted(ALLOWED_KEYS): 210 | raise AssertionError( 211 | f"yaml template has incorrect entries (allowed are {ALLOWED_KEYS})" 212 | ) 213 | return cls(**data) 214 | -------------------------------------------------------------------------------- /tests/test_py_epc_qr.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for the core functionality. 3 | """ 4 | 5 | import filecmp 6 | from os import remove 7 | 8 | import pytest 9 | 10 | from py_epc_qr import __version__ 11 | from py_epc_qr.transaction import consumer_epc_qr, epc_qr 12 | 13 | 14 | def test_version(): 15 | """ 16 | Check version. 17 | """ 18 | assert __version__ == "0.1.3" 19 | 20 | 21 | def get_valid_dummy_iban() -> str: 22 | """ 23 | Generate a valid dummy IBAN. 24 | """ 25 | return "DE" + "1" * 18 26 | 27 | 28 | class TestEpcQr: 29 | """ 30 | Class to test core functionality. 31 | """ 32 | 33 | @pytest.mark.parametrize( 34 | "fun, given, expected_txt, expected_png", 35 | [ 36 | ( 37 | consumer_epc_qr, 38 | { 39 | "beneficiary": "Wikimedia Foerdergesellschaft", 40 | "iban": "DE33100205000001194700", 41 | "amount": 123.45, 42 | "remittance": "Spende fuer Wikipedia", 43 | }, 44 | "tests/data/qr_version_002.txt", 45 | "tests/data/qr_version_002.png", 46 | ), 47 | ( 48 | epc_qr, 49 | { 50 | "version": "001", 51 | "encoding": 1, 52 | "bic": "BFSWDE33BER", 53 | "beneficiary": "Wikimedia Foerdergesellschaft", 54 | "iban": "DE33100205000001194700", 55 | "amount": 123.45, 56 | "purpose": "", 57 | "remittance_structured": "", 58 | "remittance_unstructured": "Spende fuer Wikipedia", 59 | "originator_information": "", 60 | }, 61 | "tests/data/qr_version_001.txt", 62 | "tests/data/qr_version_001.png", 63 | ), 64 | ], 65 | ) 66 | def test_epc_qr_version(self, fun, given, expected_txt, expected_png): 67 | """ 68 | Given the QR example from wikipedia 69 | When creating an epc_qr as txt and png 70 | Then everything works as intended 71 | """ 72 | qr = fun(**given) 73 | qr.to_txt(tmp_txt := "out.txt") 74 | qr.to_qr(tmp_png := "out.png") 75 | assert filecmp.cmp(tmp_txt, expected_txt) 76 | assert filecmp.cmp(tmp_png, expected_png) 77 | remove(tmp_txt) 78 | remove(tmp_png) 79 | 80 | @pytest.mark.parametrize("value", [0, 9]) 81 | def test_epc_qr_encoding_raises_exception(self, value): 82 | """ 83 | Given an invalid encoding 84 | When creating an epc qr code 85 | Then an exception is raised 86 | """ 87 | with pytest.raises(ValueError): 88 | epc_qr( 89 | version="002", 90 | encoding=value, 91 | bic="", 92 | beneficiary="ben benefit", 93 | iban=get_valid_dummy_iban(), 94 | amount=10, 95 | purpose="", 96 | remittance_structured="", 97 | remittance_unstructured="", 98 | originator_information="", 99 | ) 100 | 101 | @pytest.mark.parametrize( 102 | "value", 103 | [ 104 | 0, 105 | -1, 106 | 999999999.99 + 0.01, 107 | 10.001, 108 | 0.011, 109 | "0", 110 | "-1", 111 | "999999999.99+0.01", 112 | "10.001", 113 | "0.011", 114 | "abc", 115 | ], 116 | ) 117 | def test_epc_qr_amount_raises_exception(self, value): 118 | """ 119 | Given an invalid amount 120 | When creating an epc qr 121 | Then an exception is raised 122 | """ 123 | with pytest.raises(ValueError): 124 | consumer_epc_qr("me", get_valid_dummy_iban(), value, "") 125 | 126 | @pytest.mark.parametrize("value", ["", "a" * 71, "§23"]) 127 | def test_epc_qr_beneficiary_raises_exception(self, value): 128 | """ 129 | Given an beneficiary that is too long 130 | When creating an epc qr 131 | Then an exception is raised 132 | """ 133 | with pytest.raises(ValueError): 134 | consumer_epc_qr(value, get_valid_dummy_iban(), 12.20, "something") 135 | 136 | @pytest.mark.parametrize("value", ["000", "00", "0", "003"]) 137 | def test_epc_qr_version_raises_exception(self, value): 138 | """ 139 | Given an beneficiary that is too long 140 | When creating an epc qr 141 | Then an exception is raised 142 | """ 143 | with pytest.raises(ValueError): 144 | epc_qr(value, 1, "", "", get_valid_dummy_iban(), "", "", "", "", "") 145 | 146 | def test_epc_qr_version_001_with_empty_bic_raises_exception(self): 147 | """ 148 | Given version 001 and an empty bic 149 | When creating an epc qr 150 | Then an exception is raised 151 | """ 152 | with pytest.raises(AssertionError): 153 | epc_qr("001", 1, "", "", get_valid_dummy_iban(), 10.00, "", "", "", "") 154 | 155 | @pytest.mark.parametrize( 156 | "value", ["123", "DEA1", "DE12%", "DE12" + "a" * 31, "Hallo ,:"] 157 | ) 158 | def test_epc_qr_iban_invalid_raises_exception(self, value): 159 | """ 160 | Given an iban with an invalid country code 161 | When creating an epc qr 162 | Then an exception is raised 163 | """ 164 | with pytest.raises(ValueError): 165 | consumer_epc_qr("ben benefit", value, 0.01, "") 166 | 167 | @pytest.mark.parametrize("value", ["DE12%", "a" * 141, "%" * 141]) 168 | def test_epc_qr_unstructured_remittance_raises_exception(self, value): 169 | """ 170 | Given an iban with an invalid country code 171 | When creating an epc qr 172 | Then an exception is raised 173 | """ 174 | with pytest.raises(ValueError): 175 | consumer_epc_qr("ben benefit", get_valid_dummy_iban(), 0.01, value) 176 | 177 | def test_epc_qr_from_yaml(self): 178 | """ 179 | Given a yaml template 180 | When creating an epc qr 181 | Then everything works fine 182 | """ 183 | epc_qr_src = consumer_epc_qr.from_yaml("tests/template_version_002.yaml") 184 | epc_qe_tgt = consumer_epc_qr( 185 | beneficiary="Wikimedia Foerdergesellschaft", 186 | iban="DE33100205000001194700", 187 | amount=10, 188 | remittance="Danke", 189 | ) 190 | assert epc_qr_src.to_txt() == epc_qe_tgt.to_txt() 191 | 192 | def test_epc_qr_from_yaml_raises_exception(self): 193 | """ 194 | Given an invalid yaml template 195 | When creating an epc qr 196 | Then an exception is raised 197 | """ 198 | with pytest.raises(AssertionError): 199 | consumer_epc_qr.from_yaml("tests/template_version_002_broken.yaml") 200 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "atomicwrites" 3 | version = "1.4.0" 4 | description = "Atomic file writes." 5 | category = "dev" 6 | optional = false 7 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "21.4.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 16 | 17 | [package.extras] 18 | dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] 19 | docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] 20 | tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] 21 | tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] 22 | 23 | [[package]] 24 | name = "click" 25 | version = "8.1.3" 26 | description = "Composable command line interface toolkit" 27 | category = "main" 28 | optional = false 29 | python-versions = ">=3.7" 30 | 31 | [package.dependencies] 32 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 33 | 34 | [[package]] 35 | name = "colorama" 36 | version = "0.4.4" 37 | description = "Cross-platform colored terminal text." 38 | category = "main" 39 | optional = false 40 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 41 | 42 | [[package]] 43 | name = "more-itertools" 44 | version = "8.13.0" 45 | description = "More routines for operating on iterables, beyond itertools" 46 | category = "dev" 47 | optional = false 48 | python-versions = ">=3.5" 49 | 50 | [[package]] 51 | name = "packaging" 52 | version = "21.3" 53 | description = "Core utilities for Python packages" 54 | category = "dev" 55 | optional = false 56 | python-versions = ">=3.6" 57 | 58 | [package.dependencies] 59 | pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" 60 | 61 | [[package]] 62 | name = "pillow" 63 | version = "9.1.1" 64 | description = "Python Imaging Library (Fork)" 65 | category = "main" 66 | optional = false 67 | python-versions = ">=3.7" 68 | 69 | [package.extras] 70 | docs = ["olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinx-rtd-theme (>=1.0)", "sphinxext-opengraph"] 71 | tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] 72 | 73 | [[package]] 74 | name = "pluggy" 75 | version = "0.13.1" 76 | description = "plugin and hook calling mechanisms for python" 77 | category = "dev" 78 | optional = false 79 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 80 | 81 | [package.extras] 82 | dev = ["pre-commit", "tox"] 83 | 84 | [[package]] 85 | name = "py" 86 | version = "1.11.0" 87 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 88 | category = "dev" 89 | optional = false 90 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 91 | 92 | [[package]] 93 | name = "pyparsing" 94 | version = "3.0.9" 95 | description = "pyparsing module - Classes and methods to define and execute parsing grammars" 96 | category = "dev" 97 | optional = false 98 | python-versions = ">=3.6.8" 99 | 100 | [package.extras] 101 | diagrams = ["railroad-diagrams", "jinja2"] 102 | 103 | [[package]] 104 | name = "pytest" 105 | version = "5.4.3" 106 | description = "pytest: simple powerful testing with Python" 107 | category = "dev" 108 | optional = false 109 | python-versions = ">=3.5" 110 | 111 | [package.dependencies] 112 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 113 | attrs = ">=17.4.0" 114 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 115 | more-itertools = ">=4.0.0" 116 | packaging = "*" 117 | pluggy = ">=0.12,<1.0" 118 | py = ">=1.5.0" 119 | wcwidth = "*" 120 | 121 | [package.extras] 122 | checkqa-mypy = ["mypy (==v0.761)"] 123 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 124 | 125 | [[package]] 126 | name = "pyyaml" 127 | version = "6.0" 128 | description = "YAML parser and emitter for Python" 129 | category = "main" 130 | optional = false 131 | python-versions = ">=3.6" 132 | 133 | [[package]] 134 | name = "qrcode" 135 | version = "7.3.1" 136 | description = "QR Code image generator" 137 | category = "main" 138 | optional = false 139 | python-versions = ">=3.6" 140 | 141 | [package.dependencies] 142 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 143 | 144 | [package.extras] 145 | all = ["zest.releaser", "tox", "pytest", "pytest", "pytest-cov", "pillow"] 146 | dev = ["tox", "pytest"] 147 | maintainer = ["zest.releaser"] 148 | pil = ["pillow"] 149 | test = ["pytest", "pytest-cov"] 150 | 151 | [[package]] 152 | name = "typer" 153 | version = "0.4.1" 154 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 155 | category = "main" 156 | optional = false 157 | python-versions = ">=3.6" 158 | 159 | [package.dependencies] 160 | click = ">=7.1.1,<9.0.0" 161 | 162 | [package.extras] 163 | all = ["colorama (>=0.4.3,<0.5.0)", "shellingham (>=1.3.0,<2.0.0)"] 164 | dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)"] 165 | doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)"] 166 | test = ["shellingham (>=1.3.0,<2.0.0)", "pytest (>=4.4.0,<5.4.0)", "pytest-cov (>=2.10.0,<3.0.0)", "coverage (>=5.2,<6.0)", "pytest-xdist (>=1.32.0,<2.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "mypy (==0.910)", "black (>=22.3.0,<23.0.0)", "isort (>=5.0.6,<6.0.0)"] 167 | 168 | [[package]] 169 | name = "wcwidth" 170 | version = "0.2.5" 171 | description = "Measures the displayed width of unicode strings in a terminal" 172 | category = "dev" 173 | optional = false 174 | python-versions = "*" 175 | 176 | [metadata] 177 | lock-version = "1.1" 178 | python-versions = "^3.9" 179 | content-hash = "dcf77e6a03bac9ffc352104ed5b2782188b663100b4c9f51c06391f84f122cdc" 180 | 181 | [metadata.files] 182 | atomicwrites = [ 183 | {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, 184 | {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, 185 | ] 186 | attrs = [ 187 | {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, 188 | {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, 189 | ] 190 | click = [ 191 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 192 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 193 | ] 194 | colorama = [ 195 | {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 196 | {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 197 | ] 198 | more-itertools = [ 199 | {file = "more-itertools-8.13.0.tar.gz", hash = "sha256:a42901a0a5b169d925f6f217cd5a190e32ef54360905b9c39ee7db5313bfec0f"}, 200 | {file = "more_itertools-8.13.0-py3-none-any.whl", hash = "sha256:c5122bffc5f104d37c1626b8615b511f3427aa5389b94d61e5ef8236bfbc3ddb"}, 201 | ] 202 | packaging = [ 203 | {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, 204 | {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, 205 | ] 206 | pillow = [ 207 | {file = "Pillow-9.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:42dfefbef90eb67c10c45a73a9bc1599d4dac920f7dfcbf4ec6b80cb620757fe"}, 208 | {file = "Pillow-9.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffde4c6fabb52891d81606411cbfaf77756e3b561b566efd270b3ed3791fde4e"}, 209 | {file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c857532c719fb30fafabd2371ce9b7031812ff3889d75273827633bca0c4602"}, 210 | {file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59789a7d06c742e9d13b883d5e3569188c16acb02eeed2510fd3bfdbc1bd1530"}, 211 | {file = "Pillow-9.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d45dbe4b21a9679c3e8b3f7f4f42a45a7d3ddff8a4a16109dff0e1da30a35b2"}, 212 | {file = "Pillow-9.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e9ed59d1b6ee837f4515b9584f3d26cf0388b742a11ecdae0d9237a94505d03a"}, 213 | {file = "Pillow-9.1.1-cp310-cp310-win32.whl", hash = "sha256:b3fe2ff1e1715d4475d7e2c3e8dabd7c025f4410f79513b4ff2de3d51ce0fa9c"}, 214 | {file = "Pillow-9.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:5b650dbbc0969a4e226d98a0b440c2f07a850896aed9266b6fedc0f7e7834108"}, 215 | {file = "Pillow-9.1.1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:0b4d5ad2cd3a1f0d1df882d926b37dbb2ab6c823ae21d041b46910c8f8cd844b"}, 216 | {file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9370d6744d379f2de5d7fa95cdbd3a4d92f0b0ef29609b4b1687f16bc197063d"}, 217 | {file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b761727ed7d593e49671d1827044b942dd2f4caae6e51bab144d4accf8244a84"}, 218 | {file = "Pillow-9.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a66fe50386162df2da701b3722781cbe90ce043e7d53c1fd6bd801bca6b48d4"}, 219 | {file = "Pillow-9.1.1-cp37-cp37m-win32.whl", hash = "sha256:2b291cab8a888658d72b575a03e340509b6b050b62db1f5539dd5cd18fd50578"}, 220 | {file = "Pillow-9.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1d4331aeb12f6b3791911a6da82de72257a99ad99726ed6b63f481c0184b6fb9"}, 221 | {file = "Pillow-9.1.1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:8844217cdf66eabe39567118f229e275f0727e9195635a15e0e4b9227458daaf"}, 222 | {file = "Pillow-9.1.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b6617221ff08fbd3b7a811950b5c3f9367f6e941b86259843eab77c8e3d2b56b"}, 223 | {file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20d514c989fa28e73a5adbddd7a171afa5824710d0ab06d4e1234195d2a2e546"}, 224 | {file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:088df396b047477dd1bbc7de6e22f58400dae2f21310d9e2ec2933b2ef7dfa4f"}, 225 | {file = "Pillow-9.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53c27bd452e0f1bc4bfed07ceb235663a1df7c74df08e37fd6b03eb89454946a"}, 226 | {file = "Pillow-9.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:3f6c1716c473ebd1649663bf3b42702d0d53e27af8b64642be0dd3598c761fb1"}, 227 | {file = "Pillow-9.1.1-cp38-cp38-win32.whl", hash = "sha256:c67db410508b9de9c4694c57ed754b65a460e4812126e87f5052ecf23a011a54"}, 228 | {file = "Pillow-9.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:f054b020c4d7e9786ae0404278ea318768eb123403b18453e28e47cdb7a0a4bf"}, 229 | {file = "Pillow-9.1.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:c17770a62a71718a74b7548098a74cd6880be16bcfff5f937f900ead90ca8e92"}, 230 | {file = "Pillow-9.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3f6a6034140e9e17e9abc175fc7a266a6e63652028e157750bd98e804a8ed9a"}, 231 | {file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f372d0f08eff1475ef426344efe42493f71f377ec52237bf153c5713de987251"}, 232 | {file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09e67ef6e430f90caa093528bd758b0616f8165e57ed8d8ce014ae32df6a831d"}, 233 | {file = "Pillow-9.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66daa16952d5bf0c9d5389c5e9df562922a59bd16d77e2a276e575d32e38afd1"}, 234 | {file = "Pillow-9.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d78ca526a559fb84faaaf84da2dd4addef5edb109db8b81677c0bb1aad342601"}, 235 | {file = "Pillow-9.1.1-cp39-cp39-win32.whl", hash = "sha256:55e74faf8359ddda43fee01bffbc5bd99d96ea508d8a08c527099e84eb708f45"}, 236 | {file = "Pillow-9.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c150dbbb4a94ea4825d1e5f2c5501af7141ea95825fadd7829f9b11c97aaf6c"}, 237 | {file = "Pillow-9.1.1-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:769a7f131a2f43752455cc72f9f7a093c3ff3856bf976c5fb53a59d0ccc704f6"}, 238 | {file = "Pillow-9.1.1-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:488f3383cf5159907d48d32957ac6f9ea85ccdcc296c14eca1a4e396ecc32098"}, 239 | {file = "Pillow-9.1.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b525a356680022b0af53385944026d3486fc8c013638cf9900eb87c866afb4c"}, 240 | {file = "Pillow-9.1.1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6e760cf01259a1c0a50f3c845f9cad1af30577fd8b670339b1659c6d0e7a41dd"}, 241 | {file = "Pillow-9.1.1-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4165205a13b16a29e1ac57efeee6be2dfd5b5408122d59ef2145bc3239fa340"}, 242 | {file = "Pillow-9.1.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937a54e5694684f74dcbf6e24cc453bfc5b33940216ddd8f4cd8f0f79167f765"}, 243 | {file = "Pillow-9.1.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:baf3be0b9446a4083cc0c5bb9f9c964034be5374b5bc09757be89f5d2fa247b8"}, 244 | {file = "Pillow-9.1.1.tar.gz", hash = "sha256:7502539939b53d7565f3d11d87c78e7ec900d3c72945d4ee0e2f250d598309a0"}, 245 | ] 246 | pluggy = [ 247 | {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, 248 | {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, 249 | ] 250 | py = [ 251 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 252 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 253 | ] 254 | pyparsing = [ 255 | {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, 256 | {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, 257 | ] 258 | pytest = [ 259 | {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, 260 | {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, 261 | ] 262 | pyyaml = [ 263 | {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, 264 | {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, 265 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, 266 | {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, 267 | {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"}, 268 | {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, 269 | {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, 270 | {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, 271 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, 272 | {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, 273 | {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"}, 274 | {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, 275 | {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, 276 | {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, 277 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, 278 | {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, 279 | {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"}, 280 | {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, 281 | {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, 282 | {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, 283 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, 284 | {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, 285 | {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"}, 286 | {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, 287 | {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, 288 | {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, 289 | {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, 290 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, 291 | {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, 292 | {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"}, 293 | {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, 294 | {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, 295 | {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, 296 | ] 297 | qrcode = [ 298 | {file = "qrcode-7.3.1.tar.gz", hash = "sha256:375a6ff240ca9bd41adc070428b5dfc1dcfbb0f2507f1ac848f6cded38956578"}, 299 | ] 300 | typer = [ 301 | {file = "typer-0.4.1-py3-none-any.whl", hash = "sha256:e8467f0ebac0c81366c2168d6ad9f888efdfb6d4e1d3d5b4a004f46fa444b5c3"}, 302 | {file = "typer-0.4.1.tar.gz", hash = "sha256:5646aef0d936b2c761a10393f0384ee6b5c7fe0bb3e5cd710b17134ca1d99cff"}, 303 | ] 304 | wcwidth = [ 305 | {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, 306 | {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, 307 | ] 308 | --------------------------------------------------------------------------------