├── tests ├── __init__.py ├── conftest.py ├── test_entrypoints.py ├── test_wireguard_key.py ├── test_wireguard_config.py └── test_curve25519.py ├── src └── wireguard_tools │ ├── py.typed │ ├── __main__.py │ ├── __init__.py │ ├── wireguard_device.py │ ├── wireguard_key.py │ ├── wireguard_netlink.py │ ├── curve25519.py │ ├── wireguard_uapi.py │ ├── cli.py │ └── wireguard_config.py ├── .gitignore ├── .flake8 ├── .reuse └── dep5 ├── noxfile.py ├── LICENSES ├── 0BSD.txt ├── MIT.txt └── CC-PDDC.txt ├── tbump.toml ├── LICENSE ├── tasks.toml ├── .pre-commit-config.yaml ├── pyproject.toml ├── README.md └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/wireguard_tools/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Carnegie Mellon University 2 | # SPDX-License-Identifier: 0BSD 3 | 4 | /dist/ 5 | __pycache__/ 6 | *~ 7 | *.swp 8 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Carnegie Mellon University 2 | # SPDX-License-Identifier: MIT 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def example_wgkey() -> str: 9 | return "YpdTsMtb/QCdYKzHlzKkLcLzEbdTK0vP4ILmdcIvnhc=" 10 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2018-2021 Łukasz Langa and contributors to Black 2 | # SPDX-License-Identifier: MIT 3 | # 4 | # https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 5 | [flake8] 6 | max-line-length = 88 7 | extend-ignore = E203 8 | -------------------------------------------------------------------------------- /src/wireguard_tools/__main__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | import sys 9 | 10 | from .cli import main 11 | 12 | sys.exit(main()) 13 | -------------------------------------------------------------------------------- /.reuse/dep5: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | 3 | Files: poetry.lock 4 | Copyright: 2022-2023 Carnegie Mellon University 5 | License: 0BSD 6 | 7 | Files: README.md 8 | Copyright: 2022-2023 Carnegie Mellon University 9 | License: MIT 10 | -------------------------------------------------------------------------------- /tests/test_entrypoints.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 Carnegie Mellon University 2 | # SPDX-License-Identifier: MIT 3 | 4 | from wireguard_tools.cli import main as _cli_main # noqa: F401 5 | 6 | 7 | def test_entrypoints() -> None: 8 | """The real test was if we could import the entrypoints""" 9 | assert True 10 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Carnegie Mellon University 2 | # SPDX-License-Identifier: 0BSD 3 | 4 | # Test wireguard-tools wheel against different python versions 5 | # 6 | # pipx install nox 7 | # pipx inject nox nox-poetry 8 | # nox 9 | 10 | from nox_poetry import session 11 | 12 | 13 | @session(python=["3.13", "3.12", "3.11", "3.10", "3.9", "3.8", "3.7"]) 14 | def tests(session): 15 | session.install("pytest", ".") 16 | session.run("pytest") 17 | -------------------------------------------------------------------------------- /src/wireguard_tools/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | __version__ = "0.5.3.post.dev0" 9 | 10 | from .wireguard_config import WireguardConfig, WireguardPeer 11 | from .wireguard_device import WireguardDevice 12 | from .wireguard_key import WireguardKey 13 | 14 | __all__ = ["WireguardConfig", "WireguardDevice", "WireguardKey", "WireguardPeer"] 15 | -------------------------------------------------------------------------------- /LICENSES/0BSD.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) YEAR by AUTHOR EMAIL 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /tests/test_wireguard_key.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022 Carnegie Mellon University 2 | # SPDX-License-Identifier: MIT 3 | 4 | import pytest 5 | 6 | from wireguard_tools.wireguard_key import WireguardKey 7 | 8 | 9 | def urlsafe_encoding(key: str) -> str: 10 | return key.translate(str.maketrans("/+", "_-")).rstrip("=") 11 | 12 | 13 | class TestWireguardKey: 14 | def test_create(self, example_wgkey: str) -> None: 15 | stored_key = WireguardKey(example_wgkey) 16 | assert str(stored_key) == example_wgkey 17 | assert stored_key.urlsafe == urlsafe_encoding(example_wgkey) 18 | 19 | key_copy = WireguardKey(stored_key) 20 | assert stored_key == key_copy 21 | 22 | with pytest.raises(ValueError, match="Invalid WireGuard key length"): 23 | WireguardKey("foobar") 24 | -------------------------------------------------------------------------------- /tbump.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2023 Carnegie Mellon University 2 | # SPDX-License-Identifier: 0BSD 3 | 4 | # github_url = "https://github.com/cmusatyalab/wireguard-tools/" 5 | 6 | [version] 7 | current = "0.5.3.post.dev0" 8 | regex = ''' 9 | (?P\d+) 10 | \. 11 | (?P\d+) 12 | \. 13 | (?P\d+) 14 | (?P\.post\.dev\d+)? 15 | ''' 16 | 17 | [[field]] 18 | name = "extra" 19 | default = "" 20 | 21 | [git] 22 | message_template = "Bumping to {new_version}" 23 | tag_template = "v{new_version}" 24 | 25 | [[file]] 26 | src = "pyproject.toml" 27 | search = 'version = "{current_version}"' 28 | 29 | [[file]] 30 | src = "src/wireguard_tools/__init__.py" 31 | search = '__version__ = "{current_version}"' 32 | 33 | [[before_commit]] 34 | name = "run pre-commit checks and unit test" 35 | cmd = "poetry run poe check" 36 | 37 | [[before_commit]] 38 | name = "build sdist and wheels" 39 | cmd = "poetry run poe build" 40 | 41 | # [[after_push]] 42 | # name = "publish" 43 | # cmd = "./publish.sh" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Carnegie Mellon University 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 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 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 | -------------------------------------------------------------------------------- /src/wireguard_tools/wireguard_device.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | from abc import ABC, abstractmethod 11 | from contextlib import suppress 12 | from typing import TYPE_CHECKING, Iterator 13 | 14 | if TYPE_CHECKING: 15 | from .wireguard_config import WireguardConfig 16 | 17 | 18 | class WireguardDevice(ABC): 19 | def __init__(self, interface: str) -> None: 20 | self.interface = interface 21 | 22 | def close(self) -> None: 23 | return None 24 | 25 | @abstractmethod 26 | def get_config(self) -> WireguardConfig: ... 27 | 28 | @abstractmethod 29 | def set_config(self, config: WireguardConfig) -> None: ... 30 | 31 | @classmethod 32 | def get(cls, ifname: str) -> WireguardDevice: 33 | from .wireguard_netlink import WireguardNetlinkDevice 34 | from .wireguard_uapi import WireguardUAPIDevice 35 | 36 | with suppress(FileNotFoundError): 37 | return WireguardUAPIDevice(ifname) 38 | return WireguardNetlinkDevice(ifname) 39 | 40 | @classmethod 41 | def list(cls) -> Iterator[WireguardDevice]: 42 | from .wireguard_netlink import WireguardNetlinkDevice 43 | from .wireguard_uapi import WireguardUAPIDevice 44 | 45 | yield from WireguardNetlinkDevice.list() 46 | yield from WireguardUAPIDevice.list() 47 | -------------------------------------------------------------------------------- /LICENSES/CC-PDDC.txt: -------------------------------------------------------------------------------- 1 | 2 | The person or persons who have associated work with this document (the "Dedicator" or "Certifier") hereby either (a) certifies that, to the best of his knowledge, the work of authorship identified is in the public domain of the country from which the work is published, or (b) hereby dedicates whatever copyright the dedicators holds in the work of authorship identified below (the "Work") to the public domain. A certifier, moreover, dedicates any copyright interest he may have in the associated work, and for these purposes, is described as a "dedicator" below. 3 | 4 | A certifier has taken reasonable steps to verify the copyright status of this work. Certifier recognizes that his good faith efforts may not shield him from liability if in fact the work certified is not in the public domain. 5 | 6 | Dedicator makes this dedication for the benefit of the public at large and to the detriment of the Dedicator's heirs and successors. Dedicator intends this dedication to be an overt act of relinquishment in perpetuity of all present and future rights under copyright law, whether vested or contingent, in the Work. Dedicator understands that such relinquishment of all rights includes the relinquishment of all rights to enforce (by lawsuit or otherwise) those copyrights in the Work. 7 | 8 | Dedicator recognizes that, once placed in the public domain, the Work may be freely reproduced, distributed, transmitted, used, modified, built upon, or otherwise exploited by anyone for any purpose, commercial or non-commercial, and in any way, including by methods that have not yet been invented or conceived. 9 | -------------------------------------------------------------------------------- /tasks.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022 Carnegie Mellon University 2 | # SPDX-License-Identifier: 0BSD 3 | 4 | [tool.poe.tasks.version] 5 | help = "Show current version" 6 | cmd = "poetry run tbump current-version" 7 | 8 | [tool.poe.tasks.build] 9 | help = "Build sdist and wheel" 10 | cmd = "poetry build" 11 | 12 | [tool.poe.tasks.check] 13 | help = "Run pre-commit and unit tests" 14 | sequence = [ 15 | "poetry run pre-commit run -a", 16 | "poetry run mypy", 17 | "poetry run pytest", 18 | ] 19 | default_item_type = "cmd" 20 | 21 | [tool.poe.tasks.update-dependencies] 22 | help = "Update dependencies" 23 | sequence = [ 24 | {cmd = "poetry update"}, 25 | {cmd = "poetry run pre-commit autoupdate"}, 26 | {ref = "check"}, 27 | {cmd = "git commit --no-verify -m 'Update dependencies' poetry.lock .pre-commit-config.yaml"}, 28 | ] 29 | 30 | [tool.poe.tasks.tag-release] 31 | help = "Bump version, build, and create a release tag" 32 | cmd = "poetry run tbump --no-push ${version}" 33 | args = [{name = "version", positional = true, required=true}] 34 | 35 | [tool.poe.tasks._ensure_version] 36 | shell = "test $(poetry run tbump current-version) = ${version}" 37 | args = [{name = "version", positional = true, required=true}] 38 | 39 | [tool.poe.tasks.publish] 40 | help = "Publish release to pypi and git, bump to post-release version" 41 | sequence = [ 42 | {ref = "_ensure_version ${version}"}, 43 | {cmd = "poetry publish"}, 44 | {cmd = "poetry run tbump --non-interactive --only-patch ${version}.post.dev0"}, 45 | {cmd = "git add --update"}, 46 | {cmd = "git commit --no-verify --message 'Bumping to ${version}.post.dev0'"}, 47 | {cmd = "git push --atomic origin main v${version}"}, 48 | ] 49 | args = [{name = "version", positional = true, required = true}] 50 | 51 | [tool.poe.tasks.release] 52 | help = "Update to release version, build, tag, and publish" 53 | sequence = [ 54 | "tag-release ${version}", 55 | "publish ${version}" 56 | ] 57 | args = [{name = "version", positional = true, required = true}] 58 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2024 Carnegie Mellon University 2 | # SPDX-License-Identifier: 0BSD 3 | 4 | repos: 5 | - repo: meta 6 | hooks: 7 | - id: check-hooks-apply 8 | - id: check-useless-excludes 9 | 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v6.0.0 12 | hooks: 13 | - id: check-added-large-files 14 | - id: check-merge-conflict 15 | - id: check-toml 16 | - id: check-yaml 17 | - id: end-of-file-fixer 18 | - id: trailing-whitespace 19 | 20 | - repo: https://github.com/asottile/pyupgrade 21 | rev: v3.20.0 22 | hooks: 23 | - id: pyupgrade 24 | name: Modernize python code 25 | args: ["--py37-plus"] 26 | 27 | - repo: https://github.com/asottile/yesqa 28 | rev: v1.5.0 29 | hooks: 30 | - id: yesqa 31 | additional_dependencies: &flake_deps 32 | - flake8-bugbear 33 | 34 | - repo: https://github.com/PyCQA/isort 35 | rev: 6.0.1 36 | hooks: 37 | - id: isort 38 | name: Reorder python imports with isort 39 | 40 | - repo: https://github.com/psf/black 41 | rev: 25.9.0 42 | hooks: 43 | - id: black 44 | name: Format python code with black 45 | language_version: python3 46 | 47 | - repo: https://github.com/asottile/blacken-docs 48 | rev: 1.20.0 49 | hooks: 50 | - id: blacken-docs 51 | name: Format python code in documentation 52 | 53 | - repo: https://github.com/PyCQA/flake8 54 | rev: 7.3.0 55 | hooks: 56 | - id: flake8 57 | name: Lint python code with flake8 58 | additional_dependencies: *flake_deps 59 | 60 | - repo: https://github.com/pre-commit/mirrors-mypy 61 | rev: v1.18.2 62 | hooks: 63 | - id: mypy 64 | name: Check type hints with mypy 65 | pass_filenames: false 66 | additional_dependencies: 67 | - attrs 68 | - segno 69 | 70 | # - repo: https://github.com/fsfe/reuse-tool 71 | # rev: v1.0.0 72 | # hooks: 73 | # - id: reuse 74 | # name: Check SPDX license tags 75 | -------------------------------------------------------------------------------- /src/wireguard_tools/wireguard_key.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | """A class to represent WireGuard keys. 8 | 9 | The constructor will parse from various base64 and hex encodings. There are 10 | also class methods to generate new private keys and derive public keys. 11 | """ 12 | 13 | from __future__ import annotations 14 | 15 | from base64 import standard_b64encode, urlsafe_b64decode, urlsafe_b64encode 16 | from secrets import token_bytes 17 | 18 | from attrs import define, field 19 | 20 | from .curve25519 import RAW_KEY_LENGTH, X25519PrivateKey 21 | 22 | # Length of a wireguard key when encoded as a hexadecimal string 23 | HEX_KEY_LENGTH = 64 24 | 25 | 26 | def convert_wireguard_key(value: str | bytes | WireguardKey) -> bytes: 27 | """Decode a wireguard key to its byte string form. 28 | 29 | Accepts urlsafe encoded base64 keys with possibly missing padding. 30 | Validates that the resulting key value is a 32-byte byte string. 31 | """ 32 | if isinstance(value, WireguardKey): 33 | return value.keydata 34 | 35 | if isinstance(value, bytes): 36 | raw_key = value 37 | elif len(value) == HEX_KEY_LENGTH: 38 | raw_key = bytes.fromhex(value) 39 | else: 40 | raw_key = urlsafe_b64decode(value + "==") 41 | 42 | if len(raw_key) != RAW_KEY_LENGTH: 43 | msg = "Invalid WireGuard key length" 44 | raise ValueError(msg) 45 | 46 | return raw_key 47 | 48 | 49 | @define(frozen=True) 50 | class WireguardKey: 51 | """Representation of a WireGuard key.""" 52 | 53 | keydata: bytes = field(converter=convert_wireguard_key) 54 | 55 | @classmethod 56 | def generate(cls) -> WireguardKey: 57 | """Generate a new private key.""" 58 | random_data = token_bytes(RAW_KEY_LENGTH) 59 | # turn it into a proper curve25519 private key by fixing/clamping the value 60 | private_bytes = X25519PrivateKey.from_private_bytes(random_data).private_bytes() 61 | return cls(private_bytes) 62 | 63 | def public_key(self) -> WireguardKey: 64 | """Derive public key from private key.""" 65 | public_bytes = X25519PrivateKey.from_private_bytes(self.keydata).public_key() 66 | return WireguardKey(public_bytes) 67 | 68 | def __bool__(self) -> bool: 69 | return int.from_bytes(self.keydata, "little") != 0 70 | 71 | def __repr__(self) -> str: 72 | return f"WireguardKey('{self}')" 73 | 74 | def __str__(self) -> str: 75 | """Return a base64 encoded representation of the key.""" 76 | return standard_b64encode(self.keydata).decode("utf-8") 77 | 78 | @property 79 | def urlsafe(self) -> str: 80 | """Return a urlsafe base64 encoded representation of the key.""" 81 | return urlsafe_b64encode(self.keydata).decode("utf-8").rstrip("=") 82 | 83 | @property 84 | def hex(self) -> str: 85 | """Return a hexadecimal encoded representation of the key.""" 86 | return self.keydata.hex() 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2022-2024 Carnegie Mellon University 2 | # SPDX-License-Identifier: 0BSD 3 | 4 | [tool.poetry] 5 | name = "wireguard-tools" 6 | version = "0.5.3.post.dev0" 7 | description = "Pure python reimplementation of wireguard-tools" 8 | authors = [ 9 | "Carnegie Mellon University ", 10 | "Jan Harkes ", 11 | ] 12 | license = "MIT" 13 | readme = "README.md" 14 | repository = "https://github.com/cmusatyalab/wireguard-tools" 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Environment :: Console", 18 | "Intended Audience :: Developers", 19 | "Intended Audience :: End Users/Desktop", 20 | "Topic :: Software Development :: Libraries :: Python Modules", 21 | ] 22 | packages = [ 23 | {include = "wireguard_tools", from = "src"}, 24 | ] 25 | include = [ 26 | {path = "LICENSES", format = "sdist"}, 27 | {path = "tests", format = "sdist"}, 28 | ] 29 | 30 | [tool.poetry.scripts] 31 | wg-py = "wireguard_tools.cli:main" 32 | 33 | [tool.poetry.dependencies] 34 | python = "^3.7" 35 | attrs = ">=22.1.0" 36 | pyroute2 = "^0.7.3" 37 | segno = "^1.5.2" 38 | 39 | [tool.poetry.group.dev.dependencies] 40 | black = { version=">=24.3.0", python = "^3.8" } 41 | poethepoet = "^0.16.5" 42 | pre-commit = { version = "^3.5.0", python = "^3.8.1" } 43 | tbump = "^6.9.0" 44 | 45 | [tool.poetry.group.test.dependencies] 46 | mypy = "^0.991" 47 | pytest = "^6.2.5" 48 | pytest-mock = "^3.6.1" 49 | 50 | [tool.black] 51 | target-version = ["py37"] 52 | 53 | [tool.isort] 54 | py_version = 37 55 | profile = "black" 56 | 57 | [tool.mypy] 58 | # Ensure full coverage 59 | disallow_untyped_calls = true 60 | disallow_untyped_defs = true 61 | disallow_incomplete_defs = true 62 | disallow_untyped_decorators = true 63 | check_untyped_defs = true 64 | # Restrict dynamic typing 65 | disallow_any_generics = true 66 | disallow_subclassing_any = true 67 | warn_return_any = true 68 | # Know exactly what you're doing 69 | warn_redundant_casts = true 70 | warn_unused_ignores = true 71 | warn_unused_configs = true 72 | warn_unreachable = true 73 | show_error_codes = true 74 | # Explicit is better than implicit 75 | no_implicit_optional = true 76 | files = ["src", "tests"] 77 | 78 | [[tool.mypy.overrides]] 79 | # pytest decorators are not typed 80 | module = "tests.*" 81 | disallow_untyped_decorators = false 82 | 83 | [[tool.mypy.overrides]] 84 | module = "pyroute2.*" 85 | ignore_missing_imports = true 86 | 87 | [tool.poe] 88 | include = "tasks.toml" 89 | 90 | [tool.ruff] 91 | target-version = "py37" 92 | exclude = ["noxfile.py"] 93 | 94 | [tool.ruff.lint] 95 | select = ["ALL"] 96 | ignore = ["ANN401", "COM812", "D", "S101"] 97 | 98 | [tool.ruff.lint.per-file-ignores] 99 | "src/wireguard_tools/cli.py" = ["FIX003", "T201", "TD"] 100 | "src/wireguard_tools/curve25519.py" = ["N806"] 101 | "src/wireguard_tools/wireguard_uapi.py" = ["C901", "PLR0912"] 102 | "src/wireguard_tools/wireguard_config.py" = ["C901", "PLR0912"] 103 | "tests/*" = ["PLR2004", "S101"] 104 | 105 | [build-system] 106 | requires = ["poetry-core"] 107 | build-backend = "poetry.core.masonry.api" 108 | -------------------------------------------------------------------------------- /src/wireguard_tools/wireguard_netlink.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | from collections import defaultdict 11 | from typing import Any, Iterator 12 | 13 | import pyroute2 14 | 15 | from .wireguard_config import WireguardConfig, WireguardPeer 16 | from .wireguard_device import WireguardDevice 17 | from .wireguard_key import WireguardKey 18 | 19 | 20 | class WireguardNetlinkDevice(WireguardDevice): 21 | def __init__(self, interface: str) -> None: 22 | super().__init__(interface) 23 | self.wg = pyroute2.WireGuard() 24 | 25 | def close(self) -> None: 26 | self.wg.close() 27 | 28 | def get_config(self) -> WireguardConfig: 29 | try: 30 | info = self.wg.info(self.interface) 31 | attrs = dict(info[0]["attrs"]) 32 | except pyroute2.netlink.exceptions.NetlinkError as exc: 33 | msg = f"Unable to access interface: {exc.args[1]}" 34 | raise RuntimeError(msg) from exc 35 | 36 | try: 37 | private_key = WireguardKey(attrs["WGDEVICE_A_PRIVATE_KEY"].decode("utf-8")) 38 | except KeyError: 39 | private_key = None 40 | 41 | wgconfig = WireguardConfig( 42 | private_key=private_key or None, 43 | fwmark=attrs["WGDEVICE_A_FWMARK"] or None, 44 | listen_port=attrs["WGDEVICE_A_LISTEN_PORT"] or None, 45 | ) 46 | 47 | peer_attrs_by_pubkey: defaultdict[bytes, dict[str, Any]] = defaultdict(dict) 48 | 49 | for peer_attrs in ( 50 | dict(peer["attrs"]) 51 | for part in info 52 | for peer in part.get("WGDEVICE_A_PEERS", []) 53 | ): 54 | peer_attrs_by_pubkey[peer_attrs["WGPEER_A_PUBLIC_KEY"]].update(peer_attrs) 55 | 56 | for peer_attrs in peer_attrs_by_pubkey.values(): 57 | peer = WireguardPeer( 58 | public_key=peer_attrs["WGPEER_A_PUBLIC_KEY"].decode("utf-8"), 59 | preshared_key=WireguardKey( 60 | peer_attrs["WGPEER_A_PRESHARED_KEY"].decode("utf-8"), 61 | ) 62 | or None, 63 | endpoint_host=peer_attrs.get("WGPEER_A_ENDPOINT", {}).get("addr"), 64 | endpoint_port=peer_attrs.get("WGPEER_A_ENDPOINT", {}).get("port"), 65 | persistent_keepalive=peer_attrs[ 66 | "WGPEER_A_PERSISTENT_KEEPALIVE_INTERVAL" 67 | ] 68 | or None, 69 | allowed_ips=[ 70 | allowed_ip["addr"] 71 | for allowed_ip in peer_attrs.get("WGPEER_A_ALLOWEDIPS", []) 72 | ], 73 | last_handshake=peer_attrs.get("WGPEER_A_LAST_HANDSHAKE_TIME", {}).get( 74 | "tv_sec", 75 | ), 76 | rx_bytes=peer_attrs.get("WGPEER_A_RX_BYTES"), 77 | tx_bytes=peer_attrs.get("WGPEER_A_TX_BYTES"), 78 | ) 79 | wgconfig.add_peer(peer) 80 | return wgconfig 81 | 82 | def set_config(self, config: WireguardConfig) -> None: 83 | current_config = self.get_config() 84 | 85 | # set/update the configuration 86 | self.wg.set( 87 | interface=self.interface, 88 | private_key=str(config.private_key) if config.private_key else None, 89 | listen_port=config.listen_port, 90 | fwmark=config.fwmark, 91 | ) 92 | 93 | cur_peers = set(current_config.peers) 94 | new_peers = set(config.peers) 95 | 96 | # remove peers that are no longer in the configuration 97 | for key in cur_peers.difference(new_peers): 98 | self.wg.set(self.interface, peer={"public_key": str(key), "remove": True}) 99 | 100 | # update any changed peers 101 | for key in cur_peers.intersection(new_peers): 102 | peer = config.peers[key] 103 | if peer != current_config.peers[key]: 104 | self.wg.set(self.interface, peer=self._wg_set_peer_arg(peer)) 105 | 106 | # add any new peers 107 | for key in new_peers.difference(cur_peers): 108 | peer = config.peers[key] 109 | self.wg.set(self.interface, peer=self._wg_set_peer_arg(peer)) 110 | 111 | def _wg_set_peer_arg(self, peer: WireguardPeer) -> dict[str, str | int | list[str]]: 112 | peer_dict: dict[str, str | int | list[str]] = { 113 | "public_key": str(peer.public_key), 114 | } 115 | if peer.endpoint_host is not None and peer.endpoint_port is not None: 116 | peer_dict["endpoint_addr"] = str(peer.endpoint_host) 117 | peer_dict["endpoint_port"] = peer.endpoint_port 118 | if peer.preshared_key is not None: 119 | peer_dict["preshared_key"] = str(peer.preshared_key) 120 | if peer.persistent_keepalive is not None: 121 | peer_dict["persistent_keepalive"] = peer.persistent_keepalive 122 | peer_dict["allowed_ips"] = [str(addr) for addr in peer.allowed_ips] 123 | return peer_dict 124 | 125 | @classmethod 126 | def list(cls) -> Iterator[WireguardNetlinkDevice]: 127 | with pyroute2.NDB() as ndb: 128 | for nic in ndb.interfaces: 129 | if nic.kind == "wireguard": 130 | yield cls(nic.ifname) 131 | -------------------------------------------------------------------------------- /tests/test_wireguard_config.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2024 Carnegie Mellon University 2 | # SPDX-License-Identifier: MIT 3 | 4 | from io import StringIO 5 | from ipaddress import IPv4Address, IPv4Interface, IPv6Address, IPv6Interface 6 | 7 | import pytest 8 | 9 | from wireguard_tools.wireguard_config import WireguardConfig 10 | from wireguard_tools.wireguard_key import WireguardKey 11 | 12 | IFNAME = "wg-test" 13 | UUID = "00000000-0000-0000-0000-000000000000" 14 | TUNNEL_WGQUICK = """\ 15 | [Interface] 16 | PrivateKey = DnLEmfJzVoCRJYXzdSXIhTqnjygnhh6O+I3ErMS6OUg= 17 | Address = 10.0.0.2/32 18 | DNS = 10.0.0.1 19 | DNS = test.svc.cluster.local 20 | 21 | [Peer] 22 | PublicKey = ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE= 23 | Endpoint = 127.0.0.1:51820 24 | PersistentKeepalive = 30 25 | AllowedIPs = 10.0.0.1/32 26 | """ 27 | TUNNEL_WGCONFIG = """\ 28 | [Interface] 29 | PrivateKey = DnLEmfJzVoCRJYXzdSXIhTqnjygnhh6O+I3ErMS6OUg= 30 | 31 | [Peer] 32 | PublicKey = ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE= 33 | Endpoint = 127.0.0.1:51820 34 | PersistentKeepalive = 30 35 | AllowedIPs = 10.0.0.1/32 36 | """ 37 | TUNNEL_RESOLV_CONF = """\ 38 | nameserver 10.0.0.1 39 | search test.svc.cluster.local 40 | options ndots:5 41 | """ 42 | FRIENDLY_TAGS_CONFIG = """\ 43 | [Interface] 44 | PrivateKey = DnLEmfJzVoCRJYXzdSXIhTqnjygnhh6O+I3ErMS6OUg= 45 | Address = 10.0.0.2/32 46 | DNS = 10.0.0.1 47 | DNS = test.svc.cluster.local 48 | 49 | [Peer] 50 | # friendly_name = Friendly Peer 51 | # friendly_json = {"mood": "happy", "attitude": "friendly"} 52 | PublicKey = ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE= 53 | Endpoint = 127.0.0.1:51820 54 | PersistentKeepalive = 30 55 | AllowedIPs = 10.0.0.1/32 56 | """ 57 | FRIENDLY_TAGS_DICT = { 58 | "private_key": "DnLEmfJzVoCRJYXzdSXIhTqnjygnhh6O+I3ErMS6OUg=", 59 | "addresses": ["10.0.0.2/32"], 60 | "dns": ["10.0.0.1", "test.svc.cluster.local"], 61 | "peers": [ 62 | { 63 | "friendly_name": "Friendly Peer", 64 | "friendly_json": {"mood": "happy", "attitude": "friendly"}, 65 | "public_key": "ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE=", 66 | "endpoint": "127.0.0.1:51820", 67 | "persistent_keepalive": 30, 68 | "allowed_ips": ["10.0.0.1/32"], 69 | }, 70 | ], 71 | } 72 | MULTI_ADDR_CONFIG = """\ 73 | [Interface] 74 | PrivateKey = DnLEmfJzVoCRJYXzdSXIhTqnjygnhh6O+I3ErMS6OUg= 75 | Address = 10.0.0.2/32 76 | DNS = 10.0.0.1,test.svc.cluster.local 77 | 78 | [Peer] 79 | PublicKey = ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE= 80 | Endpoint = 127.0.0.1:51820 81 | PersistentKeepalive = 30 82 | AllowedIPs = 10.0.0.1/32 , 10.2.0.1/16 83 | """ 84 | IPV6_ADDR_CONFIG = """\ 85 | [Interface] 86 | PrivateKey = DnLEmfJzVoCRJYXzdSXIhTqnjygnhh6O+I3ErMS6OUg= 87 | Address = 10.0.0.2/32, 2001:db8:1::2/128 88 | DNS = 10.0.0.1,test.svc.cluster.local 89 | 90 | [Peer] 91 | PublicKey = ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE= 92 | Endpoint = [2001:db8::1]:51820 93 | PersistentKeepalive = 30 94 | AllowedIPs = 10.0.0.1/32, 2001:db8:1::1/64 95 | """ 96 | 97 | 98 | @pytest.fixture(scope="session") 99 | def wgconfig() -> WireguardConfig: 100 | conffile = StringIO(TUNNEL_WGQUICK) 101 | return WireguardConfig.from_wgconfig(conffile) 102 | 103 | 104 | def test_create_wgquick_config(wgconfig: WireguardConfig) -> None: 105 | assert wgconfig.to_wgconfig(wgquick_format=True) == TUNNEL_WGQUICK 106 | 107 | 108 | def test_create_wireguard_config(wgconfig: WireguardConfig) -> None: 109 | assert wgconfig.to_wgconfig() == TUNNEL_WGCONFIG 110 | 111 | 112 | def test_create_resolv_conf(wgconfig: WireguardConfig) -> None: 113 | assert wgconfig.to_resolvconf(opt_ndots=5) == TUNNEL_RESOLV_CONF 114 | 115 | 116 | def test_default_friendly_tags(wgconfig: WireguardConfig) -> None: 117 | peer_key = WireguardKey("ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE=") 118 | assert peer_key in wgconfig.peers 119 | assert wgconfig.peers[peer_key].friendly_name is None 120 | assert wgconfig.peers[peer_key].friendly_json is None 121 | 122 | 123 | def test_friendly_tags_from_wgconfig() -> None: 124 | conffile = StringIO(FRIENDLY_TAGS_CONFIG) 125 | wgconfig = WireguardConfig.from_wgconfig(conffile) 126 | 127 | peer_key = WireguardKey("ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE=") 128 | assert peer_key in wgconfig.peers 129 | peer = wgconfig.peers[peer_key] 130 | 131 | assert peer.friendly_name is not None 132 | assert peer.friendly_name == "Friendly Peer" 133 | 134 | assert peer.friendly_json is not None 135 | assert sorted(peer.friendly_json.keys()) == ["attitude", "mood"] 136 | assert peer.friendly_json["mood"] == "happy" 137 | 138 | 139 | def test_wgconfig_from_dict() -> None: 140 | wgconfig = WireguardConfig.from_dict(FRIENDLY_TAGS_DICT) 141 | assert wgconfig.to_wgconfig(wgquick_format=True) == FRIENDLY_TAGS_CONFIG 142 | 143 | 144 | def test_wgconfig_multiple_addresses() -> None: 145 | # test if we correctly parse a list of addresses 146 | conffile = StringIO(MULTI_ADDR_CONFIG) 147 | wgconfig = WireguardConfig.from_wgconfig(conffile) 148 | 149 | assert len(wgconfig.dns_servers) == 1 150 | assert IPv4Address("10.0.0.1") in wgconfig.dns_servers 151 | assert len(wgconfig.search_domains) == 1 152 | assert "test.svc.cluster.local" in wgconfig.search_domains 153 | 154 | peer_key = WireguardKey("ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE=") 155 | assert peer_key in wgconfig.peers 156 | peer = wgconfig.peers[peer_key] 157 | 158 | assert len(peer.allowed_ips) == 2 159 | assert IPv4Interface("10.0.0.1/32") in peer.allowed_ips 160 | assert IPv4Interface("10.2.0.1/16") in peer.allowed_ips 161 | 162 | 163 | def test_wgconfig_ipv6_addresses() -> None: 164 | # test if we correctly parse ipv6 addresses 165 | conffile = StringIO(IPV6_ADDR_CONFIG) 166 | wgconfig = WireguardConfig.from_wgconfig(conffile) 167 | 168 | assert len(wgconfig.addresses) == 2 169 | assert IPv4Interface("10.0.0.2/32") in wgconfig.addresses 170 | assert IPv6Interface("2001:db8:1::2/128") in wgconfig.addresses 171 | 172 | peer_key = WireguardKey("ba8AwcolBVDuhR/MKFU8O6CZrAjh7c20h6EOnQx0VRE=") 173 | assert peer_key in wgconfig.peers 174 | peer = wgconfig.peers[peer_key] 175 | 176 | assert peer.endpoint_host == IPv6Address("2001:db8::1") 177 | assert peer.endpoint_port == 51820 178 | assert len(peer.allowed_ips) == 2 179 | assert IPv4Interface("10.0.0.1/32") in peer.allowed_ips 180 | assert IPv6Interface("2001:db8:1::1/64") in peer.allowed_ips 181 | -------------------------------------------------------------------------------- /src/wireguard_tools/curve25519.py: -------------------------------------------------------------------------------- 1 | # 2 | # https://gist.github.com/nickovs/cc3c22d15f239a2640c185035c06f8a3 3 | # 4 | # SPDX-FileCopyrightText: 2021 Nicko van Someren 5 | # SPDX-License-Identifier: CC-PDDC 6 | # 7 | # There doesn't seem to be a 'Public Domain' license in the SPDX License list. 8 | # I'm guessing the closest matching one would be 'CC-PDDC' where Nicko would be 9 | # classified as the 'Dedicator'. https://spdx.org/licenses/CC-PDDC.html 10 | 11 | """A pure Python implementation of Curve25519. 12 | 13 | This module supports both a low-level interface through curve25519(base_point, secret) 14 | and curve25519_base(secret) that take 32-byte blocks of data as inputs and a higher 15 | level interface using the X25519PrivateKey and X25519PublicKey classes that are 16 | compatible with the classes in cryptography.hazmat.primitives.asymmetric.x25519 with 17 | the same names. 18 | """ 19 | # trying to keep this somewhat close to the original gist 20 | # pylint: disable=invalid-name,missing-class-docstring,missing-function-docstring 21 | 22 | # By Nicko van Someren, 2021. This code is released into the public domain. 23 | 24 | # #### WARNING #### 25 | 26 | # Since this code makes use of Python's built-in large integer types, it is NOT 27 | # EXPECTED to run in constant time. While some effort is made to minimise the time 28 | # variations, the underlying math functions are likely to have running times that are 29 | # highly value-dependent, leaving this code potentially vulnerable to timing attacks. 30 | # If this code is to be used to provide cryptographic security in an environment where 31 | # the start and end times of the execution can be guessed, inferred or measured then it 32 | # is critical that steps are taken to hide the execution time, for instance by adding a 33 | # delay so that encrypted packets are not sent until a fixed time after the _start_ of 34 | # execution. 35 | 36 | 37 | # Implements ladder multiplication as described in "Montgomery curves and the Montgomery 38 | # ladder" by Daniel J. Bernstein and Tanja Lange. https://eprint.iacr.org/2017/293.pdf 39 | 40 | # Curve25519 is a Montgomery curve defined by: 41 | # y**2 = x**3 + A * x**2 + x mod P 42 | # where P = 2**255-19 and A = 486662 43 | 44 | from __future__ import annotations 45 | 46 | from typing import Tuple 47 | 48 | Point = Tuple[int, int] 49 | 50 | RAW_KEY_LENGTH = 32 51 | P = 2**255 - 19 52 | _A = 486662 53 | 54 | 55 | def _point_add(point_n: Point, point_m: Point, point_diff: Point) -> Point: 56 | """Given the projection of two points and their difference, return their sum.""" 57 | (xn, zn) = point_n 58 | (xm, zm) = point_m 59 | (x_diff, z_diff) = point_diff 60 | x = (z_diff << 2) * (xm * xn - zm * zn) ** 2 61 | z = (x_diff << 2) * (xm * zn - zm * xn) ** 2 62 | return x % P, z % P 63 | 64 | 65 | def _point_double(point_n: Point) -> Point: 66 | """Double a point provided in projective coordinates.""" 67 | (xn, zn) = point_n 68 | xn2 = xn**2 69 | zn2 = zn**2 70 | x = (xn2 - zn2) ** 2 71 | xzn = xn * zn 72 | z = 4 * xzn * (xn2 + _A * xzn + zn2) 73 | return x % P, z % P 74 | 75 | 76 | def _const_time_swap(a: Point, b: Point, *, swap: bool) -> tuple[Point, Point]: 77 | """Swap two values in constant time.""" 78 | index = int(swap) * 2 79 | temp = (a, b, b, a) 80 | return temp[index], temp[index + 1] 81 | 82 | 83 | def _raw_curve25519(base: int, n: int) -> int: 84 | """Raise the point base to the power n.""" 85 | zero = (1, 0) 86 | one = (base, 1) 87 | mP, m1P = zero, one 88 | 89 | for i in reversed(range(256)): 90 | bit = bool(n & (1 << i)) 91 | mP, m1P = _const_time_swap(mP, m1P, swap=bit) 92 | mP, m1P = _point_double(mP), _point_add(mP, m1P, one) 93 | mP, m1P = _const_time_swap(mP, m1P, swap=bit) 94 | 95 | x, z = mP 96 | inv_z = pow(z, P - 2, P) 97 | return (x * inv_z) % P 98 | 99 | 100 | def _unpack_number(s: bytes) -> int: 101 | """Unpack 32 bytes to a 256 bit value.""" 102 | if len(s) != RAW_KEY_LENGTH: 103 | msg = "Curve25519 values must be 32 bytes" 104 | raise ValueError(msg) 105 | return int.from_bytes(s, "little") 106 | 107 | 108 | def _pack_number(n: int) -> bytes: 109 | """Pack a value into 32 bytes.""" 110 | return n.to_bytes(RAW_KEY_LENGTH, "little") 111 | 112 | 113 | def _fix_base_point(n: int) -> int: 114 | # RFC7748 section 5 115 | # u-coordinates are ... encoded as an array of bytes ... When receiving 116 | # such an array, implementations of X25519 MUST mask the most significant 117 | # bit in the final byte. 118 | n &= ~(128 << 8 * 31) 119 | return n 120 | 121 | 122 | def _fix_secret(n: int) -> int: 123 | """Mask a value to be an acceptable exponent.""" 124 | n &= ~7 125 | n &= ~(128 << 8 * 31) 126 | n |= 64 << 8 * 31 127 | return n 128 | 129 | 130 | def curve25519(base_point_raw: bytes, secret_raw: bytes) -> bytes: 131 | """Raise the base point to a given power.""" 132 | base_point = _fix_base_point(_unpack_number(base_point_raw)) 133 | secret = _fix_secret(_unpack_number(secret_raw)) 134 | return _pack_number(_raw_curve25519(base_point, secret)) 135 | 136 | 137 | def curve25519_base(secret_raw: bytes) -> bytes: 138 | """Raise the generator point to a given power.""" 139 | secret = _fix_secret(_unpack_number(secret_raw)) 140 | return _pack_number(_raw_curve25519(9, secret)) 141 | 142 | 143 | class X25519PublicKey: 144 | def __init__(self, x: int) -> None: 145 | self.x = x 146 | 147 | @classmethod 148 | def from_public_bytes(cls, data: bytes) -> X25519PublicKey: 149 | return cls(_fix_base_point(_unpack_number(data))) 150 | 151 | def public_bytes(self) -> bytes: 152 | return _pack_number(self.x) 153 | 154 | 155 | class X25519PrivateKey: 156 | def __init__(self, a: int) -> None: 157 | self.a = a 158 | 159 | @classmethod 160 | def from_private_bytes(cls, data: bytes) -> X25519PrivateKey: 161 | return cls(_fix_secret(_unpack_number(data))) 162 | 163 | def private_bytes(self) -> bytes: 164 | return _pack_number(self.a) 165 | 166 | def public_key(self) -> bytes: 167 | return _pack_number(_raw_curve25519(9, self.a)) 168 | 169 | def exchange(self, peer_public_key: X25519PublicKey | bytes) -> bytes: 170 | if isinstance(peer_public_key, bytes): 171 | peer_public_key = X25519PublicKey.from_public_bytes(peer_public_key) 172 | return _pack_number(_raw_curve25519(peer_public_key.x, self.a)) 173 | -------------------------------------------------------------------------------- /src/wireguard_tools/wireguard_uapi.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | import socket 11 | from ipaddress import ip_address, ip_interface 12 | from pathlib import Path 13 | from typing import TYPE_CHECKING, Iterator 14 | 15 | from .wireguard_config import WireguardConfig, WireguardPeer 16 | from .wireguard_device import WireguardDevice 17 | from .wireguard_key import WireguardKey 18 | 19 | if TYPE_CHECKING: 20 | import os 21 | 22 | WG_UAPI_SOCKET_DIR = Path("/var/run/wireguard") 23 | 24 | 25 | class WireguardUAPIDevice(WireguardDevice): 26 | def __init__(self, uapi_path: str | os.PathLike[str]) -> None: 27 | self.uapi_path = ( 28 | WG_UAPI_SOCKET_DIR.joinpath(uapi_path).with_suffix(".sock") 29 | if isinstance(uapi_path, str) 30 | else Path(uapi_path) 31 | ) 32 | if not self.uapi_path.exists(): 33 | msg = f"Unable to access interface: {uapi_path} not found." 34 | raise FileNotFoundError(msg) 35 | 36 | super().__init__(self.uapi_path.stem) 37 | 38 | self.uapi_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 39 | self.uapi_socket.connect(str(self.uapi_path.resolve())) 40 | self._buffer = "" 41 | 42 | def close(self) -> None: 43 | self.uapi_socket.close() 44 | 45 | def get_config(self) -> WireguardConfig: 46 | self.uapi_socket.sendall(b"get=1\n\n") 47 | response = self._recvmsg() 48 | 49 | config = WireguardConfig() 50 | peer = None 51 | for key, value in response: 52 | # interface 53 | if key == "private_key": 54 | config.private_key = WireguardKey(value) 55 | elif key in ["listen_port", "fwmark"]: 56 | setattr(config, key, int(value)) 57 | 58 | # peer 59 | elif key == "public_key": 60 | peer = WireguardPeer(public_key=value) 61 | config.add_peer(peer) 62 | elif key == "preshared_key": 63 | assert peer is not None 64 | peer.preshared_key = WireguardKey(value) or None 65 | elif key == "endpoint": 66 | assert peer is not None 67 | addr, port = value.rsplit(":", 1) 68 | peer.endpoint_host = ip_address(addr.lstrip("[").rstrip("]")) 69 | peer.endpoint_port = int(port) 70 | elif key == "persistent_keepalive_interval": 71 | assert peer is not None 72 | peer.persistent_keepalive = int(value) 73 | elif key == "allowed_ip": 74 | assert peer is not None 75 | peer.allowed_ips.append(ip_interface(value)) 76 | 77 | # device statistics 78 | elif key == "last_handshake_time_sec": 79 | assert peer is not None 80 | peer.last_handshake = int(value) * 1e0 81 | elif key == "last_handshake_time_nsec": 82 | assert peer is not None 83 | if peer.last_handshake is not None: 84 | peer.last_handshake += int(value) * 1e-9 85 | elif key in ["rx_bytes", "tx_bytes"]: 86 | assert peer is not None 87 | setattr(peer, key, int(value)) 88 | 89 | # misc 90 | elif key == "protocol_version": 91 | version = int(value) 92 | if version != 1: 93 | msg = ( 94 | f"WireguardUAPIDevice.get_config unexpected protocol {version}" 95 | ) 96 | raise RuntimeError(msg) 97 | elif key == "errno": 98 | errno = int(value) 99 | if errno != 0: 100 | msg = f"WireguardUAPIDevice.get_config failed with {errno}" 101 | raise RuntimeError(msg) 102 | return config 103 | 104 | def set_config(self, config: WireguardConfig) -> None: 105 | uapi = ["set=1"] 106 | if config.private_key is not None: 107 | uapi.append(f"private_key={config.private_key.hex}") 108 | if config.listen_port is not None: 109 | uapi.append(f"listen_port={config.listen_port}") 110 | if config.fwmark is not None: 111 | uapi.append(f"fwmark={config.fwmark}") 112 | 113 | uapi.append("replace_peers=true") 114 | for peer in config.peers.values(): 115 | # should resolve hostname for endpoint here 116 | assert not isinstance(peer.endpoint_host, str) 117 | uapi.extend( 118 | [ 119 | f"public_key={peer.public_key.hex}", 120 | f"endpoint={peer.endpoint_host}:{peer.endpoint_port}", 121 | ], 122 | ) 123 | if peer.preshared_key is not None: 124 | uapi.append(f"preshared_key={peer.preshared_key}") 125 | if peer.persistent_keepalive is not None: 126 | uapi.append( 127 | f"persistent_keepalive_interval={peer.persistent_keepalive}", 128 | ) 129 | 130 | uapi.append("replace_allowed_ips=true") 131 | uapi.extend([f"allowed_ip={address}" for address in peer.allowed_ips]) 132 | 133 | uapi.append("\n") 134 | self.uapi_socket.sendall("\n".join(uapi).encode()) 135 | 136 | response = self._recvmsg() 137 | assert len(response) == 1 138 | assert response[0][0] == "errno" 139 | errno = int(response[0][1]) 140 | if errno != 0: 141 | msg = f"WireguardUAPIDevice.set_config failed with {errno}" 142 | raise RuntimeError(msg) 143 | 144 | # a wireguard UAPI response message is a series of key=value lines 145 | # followed by an empty line 146 | def _recvmsg(self) -> list[tuple[str, str]]: 147 | message = [] 148 | while True: 149 | # read until we have at least a line 150 | while "\n" not in self._buffer: 151 | self._buffer += self.uapi_socket.recv(4096).decode("utf-8") 152 | line, self._buffer = self._buffer.split("\n", maxsplit=1) 153 | if not line: 154 | break 155 | key, value = line.split("=", maxsplit=1) 156 | message.append((key, value)) 157 | return message 158 | 159 | @classmethod 160 | def list(cls) -> Iterator[WireguardUAPIDevice]: 161 | for socket_path in WG_UAPI_SOCKET_DIR.glob("*.sock"): 162 | yield cls(socket_path.stem) 163 | -------------------------------------------------------------------------------- /src/wireguard_tools/cli.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | import argparse 11 | import os 12 | import sys 13 | from contextlib import closing 14 | from secrets import token_bytes 15 | from stat import S_IRWXO, S_ISREG 16 | from typing import Iterable 17 | 18 | from .wireguard_config import WireguardConfig 19 | from .wireguard_device import WireguardDevice 20 | from .wireguard_key import WireguardKey 21 | 22 | 23 | def show(args: argparse.Namespace) -> int: 24 | """Show the current configuration and device information.""" 25 | try: 26 | if args.interface is None: 27 | devices: Iterable[WireguardDevice] = WireguardDevice.list() 28 | else: 29 | devices = [WireguardDevice.get(args.interface)] 30 | 31 | for device in devices: 32 | print(f"interface: {device.interface}") 33 | print(device.get_config()) 34 | device.close() 35 | except RuntimeError as exc: 36 | print(exc, file=sys.stderr) 37 | return 1 38 | else: 39 | return 0 40 | 41 | 42 | def showconf(args: argparse.Namespace) -> int: 43 | """Show the configuration of a WireGuard interface, for use with `setconf`.""" 44 | try: 45 | with closing(WireguardDevice.get(args.interface)) as device: 46 | config = device.get_config() 47 | print(config.to_wgconfig(), end="") 48 | return 0 49 | except RuntimeError as exc: 50 | print(exc, file=sys.stderr) 51 | return 1 52 | 53 | 54 | def set_(_args: argparse.Namespace) -> int: 55 | """Change the current configuration, add peers, remove peers, or change peers.""" 56 | print("Not implemented yet") 57 | return 1 58 | 59 | 60 | def setconf(args: argparse.Namespace) -> int: 61 | """Apply a configuration file to a WireGuard interface.""" 62 | # XXX our device.set_config implicitly does a syncconf 63 | try: 64 | config = WireguardConfig.from_wgconfig(args.configfile) 65 | with closing(WireguardDevice.get(args.interface)) as device: 66 | device.set_config(config) 67 | return 0 68 | except RuntimeError as exc: 69 | print(exc, file=sys.stderr) 70 | return 1 71 | 72 | 73 | def addconf(_args: argparse.Namespace) -> int: 74 | """Append a configuration file to a WireGuard interface.""" 75 | print("Not implemented yet") 76 | return 1 77 | 78 | 79 | def syncconf(args: argparse.Namespace) -> int: 80 | """Synchronize a configuration file with a WireGuard interface.""" 81 | try: 82 | config = WireguardConfig.from_wgconfig(args.configfile) 83 | with closing(WireguardDevice.get(args.interface)) as device: 84 | device.set_config(config) 85 | return 0 86 | except RuntimeError as exc: 87 | print(exc, file=sys.stderr) 88 | return 1 89 | 90 | 91 | def _check_stdout() -> None: 92 | """Check and warn if stdout is a world accessible file.""" 93 | stat = os.fstat(sys.stdout.fileno()) 94 | if S_ISREG(stat.st_mode) and (stat.st_mode & S_IRWXO): 95 | print("Warning: writing to world accessible file.", file=sys.stderr) 96 | 97 | 98 | def genkey(_args: argparse.Namespace) -> int: 99 | """Generate a new private key and write it to stdout.""" 100 | _check_stdout() 101 | secret_key = WireguardKey.generate() 102 | print(secret_key) 103 | return 0 104 | 105 | 106 | def genpsk(_args: argparse.Namespace) -> int: 107 | """Generate a new preshared key and write it to stdout.""" 108 | _check_stdout() 109 | # generate a key without the curve25519 key value clamping 110 | random_data = token_bytes(32) 111 | preshared_key = WireguardKey(random_data) 112 | print(preshared_key) 113 | return 0 114 | 115 | 116 | def pubkey(_args: argparse.Namespace) -> int: 117 | """Read a private key from stdin and write a public key to stdout.""" 118 | private_key = sys.stdin.read() 119 | public_key = WireguardKey(private_key).public_key() 120 | print(public_key) 121 | return 0 122 | 123 | 124 | def strip(args: argparse.Namespace) -> int: 125 | """Output a configuration file with all wg-quick specific options removed.""" 126 | config = WireguardConfig.from_wgconfig(args.configfile) 127 | print(config.to_wgconfig()) 128 | return 0 129 | 130 | 131 | def main() -> int: 132 | parser = argparse.ArgumentParser() 133 | parser.set_defaults(func=lambda _: parser.print_help()) 134 | 135 | sub = parser.add_subparsers(title="Available subcommands") 136 | show_parser = sub.add_parser("show", help=show.__doc__, description=show.__doc__) 137 | show_parser.add_argument("interface", nargs="?") 138 | show_parser.set_defaults(func=show) 139 | 140 | showconf_parser = sub.add_parser( 141 | "showconf", 142 | help=showconf.__doc__, 143 | description=showconf.__doc__, 144 | ) 145 | showconf_parser.add_argument("interface") 146 | showconf_parser.set_defaults(func=showconf) 147 | 148 | set__parser = sub.add_parser("set", help=set_.__doc__, description=set_.__doc__) 149 | set__parser.set_defaults(func=set_) 150 | 151 | setconf_parser = sub.add_parser( 152 | "setconf", 153 | help=setconf.__doc__, 154 | description=setconf.__doc__, 155 | ) 156 | setconf_parser.add_argument("interface") 157 | setconf_parser.add_argument("configfile", type=argparse.FileType("r")) 158 | setconf_parser.set_defaults(func=setconf) 159 | 160 | addconf_parser = sub.add_parser( 161 | "addconf", 162 | help=addconf.__doc__, 163 | description=addconf.__doc__, 164 | ) 165 | addconf_parser.add_argument("interface") 166 | addconf_parser.add_argument("configfile", type=argparse.FileType("r")) 167 | addconf_parser.set_defaults(func=addconf) 168 | 169 | syncconf_parser = sub.add_parser( 170 | "syncconf", 171 | help=syncconf.__doc__, 172 | description=syncconf.__doc__, 173 | ) 174 | syncconf_parser.add_argument("interface") 175 | syncconf_parser.add_argument("configfile", type=argparse.FileType("r")) 176 | syncconf_parser.set_defaults(func=syncconf) 177 | 178 | genkey_parser = sub.add_parser( 179 | "genkey", 180 | help=genkey.__doc__, 181 | description=genkey.__doc__, 182 | ) 183 | genkey_parser.set_defaults(func=genkey) 184 | 185 | genpsk_parser = sub.add_parser( 186 | "genpsk", 187 | help=genpsk.__doc__, 188 | description=genpsk.__doc__, 189 | ) 190 | genpsk_parser.set_defaults(func=genpsk) 191 | 192 | pubkey_parser = sub.add_parser( 193 | "pubkey", 194 | help=pubkey.__doc__, 195 | description=pubkey.__doc__, 196 | ) 197 | pubkey_parser.set_defaults(func=pubkey) 198 | 199 | # from wg-quick 200 | strip_parser = sub.add_parser( 201 | "strip", 202 | help=strip.__doc__, 203 | description=strip.__doc__, 204 | ) 205 | strip_parser.add_argument("configfile", type=argparse.FileType("r")) 206 | strip_parser.set_defaults(func=strip) 207 | 208 | args = parser.parse_args() 209 | result: int = args.func(args) 210 | return result 211 | 212 | 213 | if __name__ == "__main__": 214 | sys.exit(main()) 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WireGuard-tools 2 | 3 | Pure Python reimplementation of wireguard-tools with an aim to provide easily 4 | reusable library functions to handle reading and writing of 5 | [WireGuard®](https://www.wireguard.com/) configuration files as well as 6 | interacting with WireGuard devices, both in-kernel through the Netlink API and 7 | userspace implementations through the cross-platform UAPI API. 8 | 9 | 10 | ## Installation/Usage 11 | 12 | ```sh 13 | pipx install wireguard-tools 14 | wg-py --help 15 | ``` 16 | 17 | Implemented `wg` command line functionality, 18 | 19 | - [x] show - Show configuration and device information 20 | - [x] showconf - Dump current device configuration 21 | - [ ] set - Change current configuration, add/remove/change peers 22 | - [x] setconf - Apply configuration to device 23 | - [ ] addconf - Append configuration to device 24 | - [x] syncconf - Synchronizes configuration with device 25 | - [x] genkey, genpsk, pubkey - Key generation 26 | 27 | 28 | Also includes some `wg-quick` functions, 29 | 30 | - [ ] up, down - Create and configure WireGuard device and interface 31 | - [ ] save - Dump device and interface configuration 32 | - [x] strip - Filter wg-quick settings from configuration 33 | 34 | 35 | Needs root (sudo) access to query and configure the WireGuard devices through 36 | netlink. But root doesn't know about the currently active virtualenv, you may 37 | have to pass the full path to the script in the virtualenv, or use 38 | `python3 -m wireguard_tools` 39 | 40 | ```sh 41 | sudo `which wg-py` showconf 42 | sudo /path/to/venv/python3 -m wireguard_tools showconf 43 | ``` 44 | 45 | 46 | ## Library usage 47 | 48 | ### Parsing WireGuard keys 49 | 50 | The WireguardKey class will parse base64-encoded keys, the default base64 51 | encoded string, but also an urlsafe base64 encoded variant. It also exposes 52 | both private key generating and public key deriving functions. Be sure to pass 53 | any base64 or hex encoded keys as 'str' and not 'bytes', otherwise it will 54 | assume the key was already decoded to its raw form. 55 | 56 | ```python 57 | from wireguard_tools import WireguardKey 58 | 59 | private_key = WireguardKey.generate() 60 | public_key = private_key.public_key() 61 | 62 | # print base64 encoded key 63 | print(public_key) 64 | 65 | # print urlsafe encoded key 66 | print(public_key.urlsafe) 67 | 68 | # print hexadecimal encoded key 69 | print(public_key.hex()) 70 | ``` 71 | 72 | ### Working with WireGuard configuration files 73 | 74 | The WireGuard configuration file is similar to, but not quite, the INI format 75 | because it has duplicate keys for both section names (i.e. [Peer]) as well as 76 | configuration keys within a section. According to the format description, 77 | AllowedIPs, Address, and DNS configuration keys 'may be specified multiple 78 | times'. 79 | 80 | ```python 81 | from wireguard_tools import WireguardConfig 82 | 83 | with open("wg0.conf") as fh: 84 | config = WireguardConfig.from_wgconfig(fh) 85 | ``` 86 | 87 | Also supported are the "Friendly Tags" comments as introduced by 88 | prometheus-wireguard-exporter, where a `[Peer]` section can contain 89 | comments which add a user friendly description and/or additional attributes. 90 | 91 | ``` 92 | [Peer] 93 | # friendly_name = Peer description for end users 94 | # friendly_json = {"flat"="json", "dictionary"=1, "attribute"=2} 95 | ... 96 | ``` 97 | 98 | These will show up as additional `friendly_name` and `friendly_json` attributes 99 | on the WireguardPeer object. 100 | 101 | We can also serialize and deserialize from a simple dict-based format which 102 | uses only basic JSON datatypes and, as such, can be used to convert to various 103 | formats (i.e. json, yaml, toml, pickle) either to disk or to pass over a 104 | network. 105 | 106 | ```python 107 | from wireguard_tools import WireguardConfig 108 | from pprint import pprint 109 | 110 | dict_config = dict( 111 | private_key="...", 112 | peers=[ 113 | dict( 114 | public_key="...", 115 | preshared_key=None, 116 | endpoint_host="remote_host", 117 | endpoint_port=5120, 118 | persistent_keepalive=30, 119 | allowed_ips=["0.0.0.0/0"], 120 | friendly_name="Awesome Peer", 121 | ), 122 | ], 123 | ) 124 | config = WireguardConfig.from_dict(dict_config) 125 | 126 | dict_config = config.asdict() 127 | pprint(dict_config) 128 | ``` 129 | 130 | Finally, there is a `to_qrcode` function that returns a segno.QRCode object 131 | which contains the configuration. This can be printed and scanned with the 132 | wireguard-android application. Careful with these because the QRcode exposes 133 | an easily captured copy of the private key as part of the configuration file. 134 | It is convenient, but definitely not secure. 135 | 136 | ```python 137 | from wireguard_tools import WireguardConfig 138 | from pprint import pprint 139 | 140 | dict_config = dict( 141 | private_key="...", 142 | peers=[ 143 | dict( 144 | public_key="...", 145 | preshared_key=None, 146 | endpoint_host="remote_host", 147 | endpoint_port=5120, 148 | persistent_keepalive=30, 149 | allowed_ips=["0.0.0.0/0"], 150 | ), 151 | ], 152 | ) 153 | config = WireguardConfig.from_dict(dict_config) 154 | 155 | qr = config.to_qrcode() 156 | qr.save("wgconfig.png") 157 | qr.terminal(compact=True) 158 | ``` 159 | 160 | 161 | ### Working with WireGuard devices 162 | 163 | ```python 164 | from wireguard_tools import WireguardDevice 165 | 166 | ifnames = [device.interface for device in WireguardDevice.list()] 167 | 168 | device = WireguardDevice.get("wg0") 169 | 170 | wgconfig = device.get_config() 171 | 172 | device.set_config(wgconfig) 173 | ``` 174 | 175 | ## Bugs 176 | 177 | The setconf/syncconf implementation is not quite correct. They currently use 178 | the same underlying set of operations but netlink-api's `set_config` 179 | implementation actually does something closer to syncconf, while the uapi-api 180 | implementation matches setconf. 181 | 182 | This implementation has only been tested on Linux where we've only actively 183 | used a subset of the available functionality, i.e. the common scenario is 184 | configuring an interface only once with just a single peer. 185 | 186 | 187 | ## Licenses 188 | 189 | wireguard-tools is MIT licensed 190 | 191 | Copyright (c) 2022-2024 Carnegie Mellon University 192 | 193 | Permission is hereby granted, free of charge, to any person obtaining a copy of 194 | this software and associated documentation files (the "Software"), to deal in 195 | the Software without restriction, including without limitation the rights to 196 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 197 | of the Software, and to permit persons to whom the Software is furnished to do 198 | so, subject to the following conditions: 199 | 200 | The above copyright notice and this permission notice shall be included in all 201 | copies or substantial portions of the Software. 202 | 203 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 204 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 205 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 206 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 207 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 208 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 209 | SOFTWARE. 210 | 211 | `wireguard_tools/curve25519.py` was released in the public domain 212 | 213 | Copyright Nicko van Someren, 2021. This code is released into the public domain. 214 | https://gist.github.com/nickovs/cc3c22d15f239a2640c185035c06f8a3 215 | 216 | "WireGuard" is a registered trademark of Jason A. Donenfeld. 217 | -------------------------------------------------------------------------------- /tests/test_curve25519.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2022-2024 Carnegie Mellon University 2 | # SPDX-License-Identifier: MIT 3 | 4 | from __future__ import annotations 5 | 6 | from binascii import hexlify, unhexlify 7 | from secrets import token_bytes 8 | from typing import ClassVar 9 | 10 | import pytest 11 | 12 | from wireguard_tools.curve25519 import ( 13 | RAW_KEY_LENGTH, 14 | X25519PrivateKey, 15 | curve25519, 16 | curve25519_base, 17 | ) 18 | 19 | 20 | class VectorTest: 21 | # assumes the derived class has an array named VECTORS consisting of 22 | # (scalar, input coordinate, output coordinate) tuples. 23 | VECTORS: ClassVar[list[tuple[bytes, bytes, bytes]]] = [] 24 | 25 | def test_vectors(self) -> None: 26 | for scalar, input_ucoord, output_ucoord in self.VECTORS: 27 | scalar_bytes = unhexlify(scalar) 28 | ucoord_bytes = unhexlify(input_ucoord) 29 | output_bytes = curve25519(ucoord_bytes, scalar_bytes) 30 | assert hexlify(output_bytes) == output_ucoord 31 | 32 | 33 | class TestPycurve25519(VectorTest): 34 | # https://github.com/TomCrypto/pycurve25519/blob/6cb15d7610c921956d7b33435fdf362ef7bf2ca4/test_curve25519.py 35 | VECTORS: ClassVar[list[tuple[bytes, bytes, bytes]]] = [ 36 | ( 37 | b"a8abababababababababababababababababababababababababababababab6b", 38 | b"0900000000000000000000000000000000000000000000000000000000000000", 39 | b"e3712d851a0e5d79b831c5e34ab22b41a198171de209b8b8faca23a11c624859", 40 | ), 41 | ( 42 | b"c8cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd4d", 43 | b"0900000000000000000000000000000000000000000000000000000000000000", 44 | b"b5bea823d9c9ff576091c54b7c596c0ae296884f0e150290e88455d7fba6126f", 45 | ), 46 | ] 47 | 48 | def test_private_key_format(self) -> None: 49 | for _ in range(1024): 50 | data = token_bytes(RAW_KEY_LENGTH) 51 | private_bytes = X25519PrivateKey.from_private_bytes(data).private_bytes() 52 | 53 | # check if the key is properly formatted 54 | assert (private_bytes[0] & (~248)) == 0 55 | assert (private_bytes[31] & (~127)) == 0 56 | assert (private_bytes[31] & 64) != 0 57 | 58 | def test_shared_secret(self) -> None: 59 | pri1, _, pub1 = map(unhexlify, self.VECTORS[0]) 60 | pri2, _, pub2 = map(unhexlify, self.VECTORS[1]) 61 | 62 | shared1 = curve25519(pub2, pri1) 63 | shared2 = curve25519(pub1, pri2) 64 | 65 | assert shared1 == shared2 66 | assert ( 67 | hexlify(shared1) 68 | == b"235101b705734aae8d4c2d9d0f1baf90bbb2a8c233d831a80d43815bb47ead10" 69 | ) 70 | 71 | def test_shared_secret_extended(self) -> None: 72 | for _ in range(1024): 73 | pri1 = token_bytes(RAW_KEY_LENGTH) 74 | pri2 = token_bytes(RAW_KEY_LENGTH) 75 | pub1 = curve25519_base(pri1) 76 | pub2 = curve25519_base(pri2) 77 | shared1 = curve25519(pub2, pri1) 78 | shared2 = curve25519(pub1, pri2) 79 | assert shared1 == shared2 80 | 81 | 82 | class TestRFC7748(VectorTest): 83 | # https://www.rfc-editor.org/rfc/rfc7748 84 | VECTORS: ClassVar[list[tuple[bytes, bytes, bytes]]] = [ 85 | # RFC7748 6.1 86 | ( 87 | b"77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a", 88 | b"0900000000000000000000000000000000000000000000000000000000000000", 89 | b"8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a", 90 | ), 91 | ( 92 | b"5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb", 93 | b"0900000000000000000000000000000000000000000000000000000000000000", 94 | b"de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f", 95 | ), 96 | # RFC7748 5.2 97 | ( 98 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 99 | b"e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c", 100 | b"c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552", 101 | ), 102 | ( 103 | b"4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d", 104 | b"e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493", 105 | b"95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957", 106 | ), 107 | # this last one is special because it is used in an extended test 108 | ( 109 | b"0900000000000000000000000000000000000000000000000000000000000000", 110 | b"0900000000000000000000000000000000000000000000000000000000000000", 111 | b"422c8e7a6227d7bca1350b3e2bb7279f7897b87bb6854b783c60e80311ae3079", 112 | ), 113 | ] 114 | 115 | def test_shared_secret(self) -> None: 116 | pri1, _, pub1 = map(unhexlify, self.VECTORS[0]) 117 | pri2, _, pub2 = map(unhexlify, self.VECTORS[1]) 118 | 119 | shared1 = curve25519(pub2, pri1) 120 | shared2 = curve25519(pub1, pri2) 121 | 122 | assert shared1 == shared2 123 | assert ( 124 | hexlify(shared1) 125 | == b"4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742" 126 | ) 127 | 128 | def test_rfc7748_extended(self) -> None: 129 | # keep iterating on the last one 130 | output_bytes, scalar_bytes, _ = map(unhexlify, self.VECTORS[-1]) 131 | 132 | for _ in range(1000): 133 | ucoord_bytes, scalar_bytes = scalar_bytes, output_bytes 134 | output_bytes = curve25519(ucoord_bytes, scalar_bytes) 135 | assert ( 136 | hexlify(output_bytes) 137 | == b"684cf59ba83309552800ef566f2f4d3c1c3887c49360e3875f2eb94d99532c51" 138 | ) 139 | 140 | @pytest.mark.skip(reason="Skipping long running test (20 minutes or more)") 141 | def test_rfc7748_extended_long(self) -> None: 142 | output_bytes, scalar_bytes, _ = map(unhexlify, self.VECTORS[-1]) 143 | 144 | for _ in range(1000000): 145 | ucoord_bytes, scalar_bytes = scalar_bytes, output_bytes 146 | output_bytes = curve25519(ucoord_bytes, scalar_bytes) 147 | assert ( 148 | hexlify(output_bytes) 149 | == b"7c3911e0ab2586fd864497297e575e6f3bc601c0883c30df5f4dd2d24f665424" 150 | ) 151 | 152 | 153 | class TestGcrypt(VectorTest): 154 | # https://github.com/gpg/libgcrypt/blob/ccfa9f2c1427b40483984198c3df41f8057f69f8/tests/t-cv25519.c#L514 # noqa: E501 155 | VECTORS: ClassVar[list[tuple[bytes, bytes, bytes]]] = [ 156 | # Seven tests which result in 0. 157 | ( 158 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 159 | b"0000000000000000000000000000000000000000000000000000000000000000", 160 | b"0000000000000000000000000000000000000000000000000000000000000000", 161 | ), 162 | ( 163 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 164 | b"0100000000000000000000000000000000000000000000000000000000000000", 165 | b"0000000000000000000000000000000000000000000000000000000000000000", 166 | ), 167 | ( 168 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 169 | b"e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800", 170 | b"0000000000000000000000000000000000000000000000000000000000000000", 171 | ), 172 | ( 173 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 174 | b"5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157", 175 | b"0000000000000000000000000000000000000000000000000000000000000000", 176 | ), 177 | ( 178 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 179 | b"ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", 180 | b"0000000000000000000000000000000000000000000000000000000000000000", 181 | ), 182 | ( 183 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 184 | b"edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", 185 | b"0000000000000000000000000000000000000000000000000000000000000000", 186 | ), 187 | ( 188 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 189 | b"eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", 190 | b"0000000000000000000000000000000000000000000000000000000000000000", 191 | ), 192 | # Five tests which result in 0 if decodeUCoordinate didn't change MSB. 193 | ( 194 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 195 | b"cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880", 196 | b"7ce548bc4919008436244d2da7a9906528fe3a6d278047654bd32d8acde9707b", 197 | ), 198 | ( 199 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 200 | b"4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7", 201 | b"e17902e989a034acdf7248260e2c94cdaf2fe1e72aaac7024a128058b6189939", 202 | ), 203 | ( 204 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 205 | b"d9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 206 | b"ea6e6ddf0685c31e152d5818441ac9ac8db1a01f3d6cb5041b07443a901e7145", 207 | ), 208 | ( 209 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 210 | b"daffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 211 | b"845ddce7b3a9b3ee01a2f1fd4282ad293310f7a232cbc5459fb35d94bccc9d05", 212 | ), 213 | ( 214 | b"a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4", 215 | b"dbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", 216 | b"6989e2cb1cea159acf121b0af6bf77493189c9bd32c2dac71669b540f9488247", 217 | ), 218 | ] 219 | -------------------------------------------------------------------------------- /src/wireguard_tools/wireguard_config.py: -------------------------------------------------------------------------------- 1 | # 2 | # Pure Python reimplementation of wireguard-tools 3 | # 4 | # Copyright (c) 2022-2024 Carnegie Mellon University 5 | # SPDX-License-Identifier: MIT 6 | # 7 | 8 | from __future__ import annotations 9 | 10 | import json 11 | import re 12 | from ipaddress import ( 13 | IPv4Address, 14 | IPv4Interface, 15 | IPv6Address, 16 | IPv6Interface, 17 | ip_address, 18 | ip_interface, 19 | ) 20 | from typing import Any, Sequence, TextIO, TypeVar, Union 21 | 22 | from attrs import asdict, define, field 23 | from attrs.converters import optional 24 | from attrs.setters import convert as setters_convert 25 | from segno import QRCode, make_qr 26 | 27 | from .wireguard_key import WireguardKey 28 | 29 | SimpleJsonTypes = Union[str, int, float, bool, None] 30 | T = TypeVar("T") 31 | 32 | 33 | def _ipaddress_or_host( 34 | host: IPv4Address | IPv6Address | str, 35 | ) -> IPv4Address | IPv6Address | str: 36 | if isinstance(host, (IPv4Address, IPv6Address)): 37 | return host 38 | try: 39 | return ip_address(host.lstrip("[").rstrip("]")) 40 | except ValueError: 41 | return host 42 | 43 | 44 | def _list_of_ipaddress( 45 | hosts: Sequence[IPv4Address | IPv6Address | str], 46 | ) -> Sequence[IPv4Address | IPv6Address]: 47 | return [ip_address(host) for host in hosts] 48 | 49 | 50 | def _list_of_ipinterface( 51 | hosts: Sequence[IPv4Interface | IPv6Interface | str], 52 | ) -> Sequence[IPv4Interface | IPv6Interface]: 53 | return [ip_interface(host) for host in hosts] 54 | 55 | 56 | @define(on_setattr=setters_convert) 57 | class WireguardPeer: 58 | public_key: WireguardKey = field(converter=WireguardKey) 59 | preshared_key: WireguardKey | None = field( 60 | converter=optional(WireguardKey), 61 | default=None, 62 | ) 63 | endpoint_host: IPv4Address | IPv6Address | str | None = field( 64 | converter=optional(_ipaddress_or_host), 65 | default=None, 66 | ) 67 | endpoint_port: int | None = field(converter=optional(int), default=None) 68 | persistent_keepalive: int | None = field(converter=optional(int), default=None) 69 | allowed_ips: list[IPv4Interface | IPv6Interface] = field( 70 | converter=_list_of_ipinterface, 71 | factory=list, 72 | ) 73 | # comment tags that can be parsed by prometheus-wireguard-exporter 74 | friendly_name: str | None = None 75 | friendly_json: dict[str, SimpleJsonTypes] | None = None 76 | 77 | # peer statistics from device 78 | last_handshake: float | None = field( 79 | converter=optional(float), 80 | default=None, 81 | eq=False, 82 | ) 83 | rx_bytes: int | None = field(converter=optional(int), default=None, eq=False) 84 | tx_bytes: int | None = field(converter=optional(int), default=None, eq=False) 85 | 86 | @classmethod 87 | def from_dict(cls, config_dict: dict[str, Any]) -> WireguardPeer: 88 | endpoint = config_dict.pop("endpoint", None) 89 | if endpoint is not None: 90 | host, port = endpoint.rsplit(":", 1) 91 | config_dict["endpoint_host"] = host 92 | config_dict["endpoint_port"] = int(port) 93 | return cls(**config_dict) 94 | 95 | def asdict(self) -> dict[str, Any]: 96 | def _filter(_attr: Any, value: Any) -> bool: 97 | return value is not None 98 | 99 | def _serializer(_instance: type, _field: Any, value: T) -> T | str: 100 | if isinstance( 101 | value, 102 | (IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, WireguardKey), 103 | ): 104 | return str(value) 105 | return value 106 | 107 | return asdict(self, filter=_filter, value_serializer=_serializer) 108 | 109 | @classmethod 110 | def from_wgconfig(cls, config: Sequence[tuple[str, str]]) -> WireguardPeer: 111 | conf: dict[str, Any] = {} 112 | for key_, value in config: 113 | key = key_.lower() 114 | if key == "publickey": 115 | conf["public_key"] = WireguardKey(value) 116 | elif key == "presharedkey": 117 | conf["preshared_key"] = WireguardKey(value) 118 | elif key == "endpoint": 119 | host, port = value.rsplit(":", 1) 120 | conf["endpoint_host"] = host 121 | conf["endpoint_port"] = int(port) 122 | elif key == "persistentkeepalive": 123 | conf["persistent_keepalive"] = int(value) 124 | elif key == "allowedips": 125 | conf.setdefault("allowed_ips", []).extend( 126 | ip_interface(addr.strip()) for addr in value.split(",") 127 | ) 128 | elif key == "# friendly_name": 129 | conf["friendly_name"] = value 130 | elif key == "# friendly_json": 131 | conf["friendly_json"] = json.loads(value) 132 | return cls(**conf) 133 | 134 | def as_wgconfig_snippet(self) -> list[str]: 135 | conf = ["\n[Peer]"] 136 | if self.friendly_name: 137 | conf.append(f"# friendly_name = {self.friendly_name}") 138 | if self.friendly_json is not None: 139 | value = json.dumps(self.friendly_json) 140 | conf.append(f"# friendly_json = {value}") 141 | conf.append(f"PublicKey = {self.public_key}") 142 | if self.preshared_key: 143 | conf.append(f"PresharedKey = {self.preshared_key}") 144 | if self.endpoint_host: 145 | conf.append(f"Endpoint = {self.endpoint_host}:{self.endpoint_port}") 146 | if self.persistent_keepalive: 147 | conf.append(f"PersistentKeepalive = {self.persistent_keepalive}") 148 | conf.extend([f"AllowedIPs = {addr}" for addr in self.allowed_ips]) 149 | return conf 150 | 151 | def __str__(self) -> str: 152 | desc = [f"peer: {self.public_key}"] 153 | if self.preshared_key: 154 | desc.append(f" preshared key: {self.preshared_key}") 155 | if self.endpoint_host: 156 | desc.append(f" endpoint: {self.endpoint_host}:{self.endpoint_port}") 157 | if self.persistent_keepalive: 158 | desc.append(f" persistent keepalive: {self.persistent_keepalive}") 159 | if self.allowed_ips: 160 | allowed_ips = ", ".join(str(addr) for addr in self.allowed_ips) 161 | desc.append(f" allowed ips: {allowed_ips}") 162 | if self.last_handshake: 163 | desc.append(f" last handshake: {self.last_handshake}") 164 | if self.rx_bytes and self.tx_bytes: 165 | desc.append( 166 | " transfer:" 167 | f" {self.rx_bytes / 1024:.2f} KiB received, " 168 | f" {self.tx_bytes / 1024:.2f} KiB sent", 169 | ) 170 | return "\n".join(desc) 171 | 172 | 173 | @define(on_setattr=setters_convert) 174 | class WireguardConfig: 175 | private_key: WireguardKey | None = field( 176 | converter=optional(WireguardKey), 177 | default=None, 178 | repr=lambda _: "(hidden)", 179 | ) 180 | fwmark: int | None = field(converter=optional(int), default=None) 181 | listen_port: int | None = field(converter=optional(int), default=None) 182 | peers: dict[WireguardKey, WireguardPeer] = field(factory=dict) 183 | 184 | # wg-quick format extensions 185 | addresses: list[IPv4Interface | IPv6Interface] = field( 186 | converter=_list_of_ipinterface, 187 | factory=list, 188 | ) 189 | dns_servers: list[IPv4Address | IPv6Address] = field( 190 | converter=_list_of_ipaddress, 191 | factory=list, 192 | ) 193 | search_domains: list[str] = field(factory=list) 194 | mtu: int | None = field(converter=optional(int), default=None) 195 | table: str | None = field(default=None) 196 | preup: list[str] = field(factory=list) 197 | postup: list[str] = field(factory=list) 198 | predown: list[str] = field(factory=list) 199 | postdown: list[str] = field(factory=list) 200 | saveconfig: bool = field(default=False) 201 | 202 | # wireguard-android specific extensions 203 | included_applications: list[str] = field(factory=list) 204 | excluded_applications: list[str] = field(factory=list) 205 | 206 | @classmethod 207 | def from_dict(cls, config_dict: dict[str, Any]) -> WireguardConfig: 208 | config_dict = config_dict.copy() 209 | 210 | dns = config_dict.pop("dns", []) 211 | peers = config_dict.pop("peers", []) 212 | 213 | config = cls(**config_dict) 214 | 215 | for item in dns: 216 | config._add_dns_entry(item) 217 | 218 | for peer_dict in peers: 219 | peer = WireguardPeer.from_dict(peer_dict) 220 | config.add_peer(peer) 221 | return config 222 | 223 | def asdict(self) -> dict[str, Any]: 224 | def _filter(_attr: Any, value: Any) -> bool: 225 | return value is not None 226 | 227 | def _serializer( 228 | _instance: type, 229 | _field: Any, 230 | value: T, 231 | ) -> list[dict[str, Any]] | T | str: 232 | if isinstance(value, dict): 233 | return list(value.values()) 234 | if isinstance( 235 | value, 236 | (IPv4Address, IPv4Interface, IPv6Address, IPv6Interface, WireguardKey), 237 | ): 238 | return str(value) 239 | return value 240 | 241 | return asdict(self, filter=_filter, value_serializer=_serializer) 242 | 243 | @classmethod 244 | def from_wgconfig(cls, configfile: TextIO) -> WireguardConfig: 245 | text = configfile.read() 246 | _pre, *parts = re.split( 247 | r"^\[(Interface|Peer)\]$", 248 | text, 249 | flags=re.IGNORECASE | re.MULTILINE, 250 | ) 251 | sections = [section.lower() for section in parts[0::2]] 252 | if sections.count("interface") > 1: 253 | msg = "More than one [Interface] section in config file" 254 | raise ValueError(msg) 255 | 256 | config = cls() 257 | for section, content in zip(sections, parts[1::2]): 258 | key_value = [ 259 | (match.group(1), match.group(3)) 260 | for match in re.finditer( 261 | r"^((# )?\w+)\s*=\s*(.+)$", 262 | content, 263 | re.MULTILINE, 264 | ) 265 | ] 266 | if section == "interface": 267 | config._update_from_conf(key_value) 268 | else: 269 | peer = WireguardPeer.from_wgconfig(key_value) 270 | config.add_peer(peer) 271 | return config 272 | 273 | def _update_from_conf(self, key_value: Sequence[tuple[str, str]]) -> None: 274 | for key_, value in key_value: 275 | key = key_.lower() 276 | if key == "privatekey": 277 | self.private_key = WireguardKey(value) 278 | elif key == "fwmark": 279 | self.fwmark = int(value) 280 | elif key == "listenport": 281 | self.listen_port = int(value) 282 | # wg-quick specific extensions 283 | elif key == "address": 284 | self.addresses.extend( 285 | ip_interface(addr.strip()) for addr in value.split(",") 286 | ) 287 | elif key == "dns": 288 | for item in value.split(","): 289 | self._add_dns_entry(item.strip()) 290 | elif key == "mtu": 291 | self.mtu = int(value) 292 | elif key == "table": 293 | self.table = value 294 | elif key == "preup": 295 | self.preup.append(value) 296 | elif key == "postup": 297 | self.postup.append(value) 298 | elif key == "predown": 299 | self.predown.append(value) 300 | elif key == "postdown": 301 | self.postdown.append(value) 302 | elif key == "saveconfig": 303 | self.saveconfig = value == "true" 304 | # wireguard-android specific extensions 305 | elif key == "includedapplications": 306 | self.included_applications.extend( 307 | item.strip() for item in value.split(",") 308 | ) 309 | elif key == "excludedapplications": 310 | self.excluded_applications.extend( 311 | item.strip() for item in value.split(",") 312 | ) 313 | 314 | def _add_dns_entry(self, item: str) -> None: 315 | try: 316 | self.dns_servers.append(ip_address(item)) 317 | except ValueError: 318 | self.search_domains.append(item) 319 | 320 | def add_peer(self, peer: WireguardPeer) -> None: 321 | self.peers[peer.public_key] = peer 322 | 323 | def del_peer(self, peer_key: WireguardKey) -> None: 324 | del self.peers[peer_key] 325 | 326 | def to_wgconfig(self, *, wgquick_format: bool = False) -> str: 327 | conf = ["[Interface]"] 328 | if self.private_key is not None: 329 | conf.append(f"PrivateKey = {self.private_key}") 330 | if self.listen_port is not None: 331 | conf.append(f"ListenPort = {self.listen_port}") 332 | if self.fwmark is not None: 333 | conf.append(f"FwMark = {self.fwmark}") 334 | if wgquick_format: 335 | if self.mtu is not None: 336 | conf.append(f"MTU = {self.mtu}") 337 | conf.extend([f"Address = {addr}" for addr in self.addresses]) 338 | conf.extend([f"DNS = {addr}" for addr in self.dns_servers]) 339 | conf.extend([f"DNS = {domain}" for domain in self.search_domains]) 340 | if self.table is not None: 341 | conf.append(f"Table = {self.table}") 342 | conf.extend([f"PreUp = {cmd}" for cmd in self.preup]) 343 | conf.extend([f"PostUp = {cmd}" for cmd in self.postup]) 344 | conf.extend([f"PreDown = {cmd}" for cmd in self.predown]) 345 | conf.extend([f"PostDown = {cmd}" for cmd in self.postdown]) 346 | if self.saveconfig: 347 | conf.append("SaveConfig = true") 348 | 349 | # wireguard-android specific extensions 350 | if self.included_applications: 351 | apps = ", ".join(self.included_applications) 352 | conf.append(f"IncludedApplications = {apps}") 353 | if self.excluded_applications: 354 | apps = ", ".join(self.excluded_applications) 355 | conf.append(f"ExcludedApplications = {apps}") 356 | for peer in self.peers.values(): 357 | conf.extend(peer.as_wgconfig_snippet()) 358 | conf.append("") 359 | return "\n".join(conf) 360 | 361 | def to_resolvconf(self, opt_ndots: int | None = None) -> str: 362 | conf = [f"nameserver {addr}" for addr in self.dns_servers] 363 | if self.search_domains: 364 | search_domains = " ".join(self.search_domains) 365 | conf.append(f"search {search_domains}") 366 | if opt_ndots is not None: 367 | conf.append(f"options ndots:{opt_ndots}") 368 | conf.append("") 369 | return "\n".join(conf) 370 | 371 | def to_qrcode(self) -> QRCode: 372 | config = self.to_wgconfig(wgquick_format=True) 373 | return make_qr(config, mode="byte", encoding="utf-8", eci=True) 374 | 375 | def __str__(self) -> str: 376 | desc = [] 377 | if self.private_key is not None: 378 | desc.append(f" public key: {self.private_key.public_key()}") 379 | desc.append(" private key: (hidden)") 380 | if self.listen_port is not None: 381 | desc.append(f" listening port: {self.listen_port}") 382 | if self.fwmark is not None: 383 | desc.append(f" fwmark: {self.fwmark}") 384 | for peer in self.peers.values(): 385 | desc.append("") 386 | desc.append(str(peer)) 387 | return "\n".join(desc) 388 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "atomicwrites" 5 | version = "1.4.1" 6 | description = "Atomic file writes." 7 | optional = false 8 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 9 | groups = ["test"] 10 | markers = "sys_platform == \"win32\"" 11 | files = [ 12 | {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, 13 | ] 14 | 15 | [[package]] 16 | name = "attrs" 17 | version = "24.2.0" 18 | description = "Classes Without Boilerplate" 19 | optional = false 20 | python-versions = ">=3.7" 21 | groups = ["main", "test"] 22 | files = [ 23 | {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, 24 | {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, 25 | ] 26 | 27 | [package.dependencies] 28 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 29 | 30 | [package.extras] 31 | benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 32 | cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 33 | dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 34 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 35 | tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\"", "pytest-xdist[psutil]"] 36 | tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.9\" and python_version < \"3.13\""] 37 | 38 | [[package]] 39 | name = "black" 40 | version = "24.8.0" 41 | description = "The uncompromising code formatter." 42 | optional = false 43 | python-versions = ">=3.8" 44 | groups = ["dev"] 45 | markers = "python_version >= \"3.8\"" 46 | files = [ 47 | {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, 48 | {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, 49 | {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, 50 | {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, 51 | {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, 52 | {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, 53 | {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, 54 | {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, 55 | {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, 56 | {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, 57 | {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, 58 | {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, 59 | {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, 60 | {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, 61 | {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, 62 | {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, 63 | {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, 64 | {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, 65 | {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, 66 | {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, 67 | {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, 68 | {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, 69 | ] 70 | 71 | [package.dependencies] 72 | click = ">=8.0.0" 73 | mypy-extensions = ">=0.4.3" 74 | packaging = ">=22.0" 75 | pathspec = ">=0.9.0" 76 | platformdirs = ">=2" 77 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 78 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 79 | 80 | [package.extras] 81 | colorama = ["colorama (>=0.4.3)"] 82 | d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] 83 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 84 | uvloop = ["uvloop (>=0.15.2)"] 85 | 86 | [[package]] 87 | name = "cfgv" 88 | version = "3.4.0" 89 | description = "Validate configuration and produce human readable error messages." 90 | optional = false 91 | python-versions = ">=3.8" 92 | groups = ["dev"] 93 | markers = "python_full_version >= \"3.8.1\"" 94 | files = [ 95 | {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, 96 | {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, 97 | ] 98 | 99 | [[package]] 100 | name = "cli-ui" 101 | version = "0.17.2" 102 | description = "Build Nice User Interfaces In The Terminal" 103 | optional = false 104 | python-versions = ">=3.7,<4.0" 105 | groups = ["dev"] 106 | files = [ 107 | {file = "cli-ui-0.17.2.tar.gz", hash = "sha256:2f67e50cf474e76ad160c3e660bbad98bf8b8dfb8d847765f3a261b7e13c05fa"}, 108 | {file = "cli_ui-0.17.2-py3-none-any.whl", hash = "sha256:6a1ebdbbcd83a0fa06b2f63f4434082a3ba8664aebedd91f1ff86b9e4289d53e"}, 109 | ] 110 | 111 | [package.dependencies] 112 | colorama = ">=0.4.1,<0.5.0" 113 | tabulate = ">=0.8.3,<0.9.0" 114 | unidecode = ">=1.0.23,<2.0.0" 115 | 116 | [[package]] 117 | name = "click" 118 | version = "8.1.8" 119 | description = "Composable command line interface toolkit" 120 | optional = false 121 | python-versions = ">=3.7" 122 | groups = ["dev"] 123 | markers = "python_version >= \"3.8\"" 124 | files = [ 125 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 126 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 127 | ] 128 | 129 | [package.dependencies] 130 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 131 | 132 | [[package]] 133 | name = "colorama" 134 | version = "0.4.6" 135 | description = "Cross-platform colored terminal text." 136 | optional = false 137 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 138 | groups = ["dev", "test"] 139 | files = [ 140 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 141 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 142 | ] 143 | markers = {test = "sys_platform == \"win32\""} 144 | 145 | [[package]] 146 | name = "distlib" 147 | version = "0.4.0" 148 | description = "Distribution utilities" 149 | optional = false 150 | python-versions = "*" 151 | groups = ["dev"] 152 | markers = "python_full_version >= \"3.8.1\"" 153 | files = [ 154 | {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, 155 | {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, 156 | ] 157 | 158 | [[package]] 159 | name = "docopt" 160 | version = "0.6.2" 161 | description = "Pythonic argument parser, that will make you smile" 162 | optional = false 163 | python-versions = "*" 164 | groups = ["dev"] 165 | files = [ 166 | {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, 167 | ] 168 | 169 | [[package]] 170 | name = "filelock" 171 | version = "3.16.1" 172 | description = "A platform independent file lock." 173 | optional = false 174 | python-versions = ">=3.8" 175 | groups = ["dev"] 176 | markers = "python_full_version >= \"3.8.1\"" 177 | files = [ 178 | {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, 179 | {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, 180 | ] 181 | 182 | [package.extras] 183 | docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] 184 | testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] 185 | typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] 186 | 187 | [[package]] 188 | name = "identify" 189 | version = "2.6.1" 190 | description = "File identification library for Python" 191 | optional = false 192 | python-versions = ">=3.8" 193 | groups = ["dev"] 194 | markers = "python_full_version >= \"3.8.1\"" 195 | files = [ 196 | {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, 197 | {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, 198 | ] 199 | 200 | [package.extras] 201 | license = ["ukkonen"] 202 | 203 | [[package]] 204 | name = "importlib-metadata" 205 | version = "6.7.0" 206 | description = "Read metadata from Python packages" 207 | optional = false 208 | python-versions = ">=3.7" 209 | groups = ["main", "test"] 210 | files = [ 211 | {file = "importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, 212 | {file = "importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, 213 | ] 214 | markers = {main = "python_version < \"3.10\"", test = "python_version == \"3.7\""} 215 | 216 | [package.dependencies] 217 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 218 | zipp = ">=0.5" 219 | 220 | [package.extras] 221 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 222 | perf = ["ipython"] 223 | testing = ["flufl.flake8", "importlib-resources (>=1.3) ; python_version < \"3.9\"", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf (>=0.9.2)", "pytest-ruff"] 224 | 225 | [[package]] 226 | name = "iniconfig" 227 | version = "2.0.0" 228 | description = "brain-dead simple config-ini parsing" 229 | optional = false 230 | python-versions = ">=3.7" 231 | groups = ["test"] 232 | files = [ 233 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 234 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 235 | ] 236 | 237 | [[package]] 238 | name = "mypy" 239 | version = "0.991" 240 | description = "Optional static typing for Python" 241 | optional = false 242 | python-versions = ">=3.7" 243 | groups = ["test"] 244 | files = [ 245 | {file = "mypy-0.991-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab"}, 246 | {file = "mypy-0.991-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d"}, 247 | {file = "mypy-0.991-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6"}, 248 | {file = "mypy-0.991-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb"}, 249 | {file = "mypy-0.991-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305"}, 250 | {file = "mypy-0.991-cp310-cp310-win_amd64.whl", hash = "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c"}, 251 | {file = "mypy-0.991-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372"}, 252 | {file = "mypy-0.991-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f"}, 253 | {file = "mypy-0.991-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33"}, 254 | {file = "mypy-0.991-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05"}, 255 | {file = "mypy-0.991-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad"}, 256 | {file = "mypy-0.991-cp311-cp311-win_amd64.whl", hash = "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297"}, 257 | {file = "mypy-0.991-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813"}, 258 | {file = "mypy-0.991-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711"}, 259 | {file = "mypy-0.991-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd"}, 260 | {file = "mypy-0.991-cp37-cp37m-win_amd64.whl", hash = "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef"}, 261 | {file = "mypy-0.991-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a"}, 262 | {file = "mypy-0.991-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93"}, 263 | {file = "mypy-0.991-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf"}, 264 | {file = "mypy-0.991-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135"}, 265 | {file = "mypy-0.991-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70"}, 266 | {file = "mypy-0.991-cp38-cp38-win_amd64.whl", hash = "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243"}, 267 | {file = "mypy-0.991-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d"}, 268 | {file = "mypy-0.991-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5"}, 269 | {file = "mypy-0.991-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3"}, 270 | {file = "mypy-0.991-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648"}, 271 | {file = "mypy-0.991-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476"}, 272 | {file = "mypy-0.991-cp39-cp39-win_amd64.whl", hash = "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461"}, 273 | {file = "mypy-0.991-py3-none-any.whl", hash = "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb"}, 274 | {file = "mypy-0.991.tar.gz", hash = "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06"}, 275 | ] 276 | 277 | [package.dependencies] 278 | mypy-extensions = ">=0.4.3" 279 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 280 | typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} 281 | typing-extensions = ">=3.10" 282 | 283 | [package.extras] 284 | dmypy = ["psutil (>=4.0)"] 285 | install-types = ["pip"] 286 | python2 = ["typed-ast (>=1.4.0,<2)"] 287 | reports = ["lxml"] 288 | 289 | [[package]] 290 | name = "mypy-extensions" 291 | version = "1.0.0" 292 | description = "Type system extensions for programs checked with the mypy type checker." 293 | optional = false 294 | python-versions = ">=3.5" 295 | groups = ["dev", "test"] 296 | files = [ 297 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 298 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 299 | ] 300 | markers = {dev = "python_version >= \"3.8\""} 301 | 302 | [[package]] 303 | name = "nodeenv" 304 | version = "1.9.1" 305 | description = "Node.js virtual environment builder" 306 | optional = false 307 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 308 | groups = ["dev"] 309 | markers = "python_full_version >= \"3.8.1\"" 310 | files = [ 311 | {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, 312 | {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, 313 | ] 314 | 315 | [[package]] 316 | name = "packaging" 317 | version = "24.0" 318 | description = "Core utilities for Python packages" 319 | optional = false 320 | python-versions = ">=3.7" 321 | groups = ["dev", "test"] 322 | files = [ 323 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 324 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 325 | ] 326 | markers = {dev = "python_version >= \"3.8\""} 327 | 328 | [[package]] 329 | name = "pastel" 330 | version = "0.2.1" 331 | description = "Bring colors to your terminal." 332 | optional = false 333 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 334 | groups = ["dev"] 335 | files = [ 336 | {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, 337 | {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, 338 | ] 339 | 340 | [[package]] 341 | name = "pathspec" 342 | version = "0.12.1" 343 | description = "Utility library for gitignore style pattern matching of file paths." 344 | optional = false 345 | python-versions = ">=3.8" 346 | groups = ["dev"] 347 | markers = "python_version >= \"3.8\"" 348 | files = [ 349 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 350 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 351 | ] 352 | 353 | [[package]] 354 | name = "platformdirs" 355 | version = "4.3.6" 356 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 357 | optional = false 358 | python-versions = ">=3.8" 359 | groups = ["dev"] 360 | markers = "python_version >= \"3.8\"" 361 | files = [ 362 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 363 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 364 | ] 365 | 366 | [package.extras] 367 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 368 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 369 | type = ["mypy (>=1.11.2)"] 370 | 371 | [[package]] 372 | name = "pluggy" 373 | version = "1.2.0" 374 | description = "plugin and hook calling mechanisms for python" 375 | optional = false 376 | python-versions = ">=3.7" 377 | groups = ["test"] 378 | files = [ 379 | {file = "pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849"}, 380 | {file = "pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3"}, 381 | ] 382 | 383 | [package.dependencies] 384 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 385 | 386 | [package.extras] 387 | dev = ["pre-commit", "tox"] 388 | testing = ["pytest", "pytest-benchmark"] 389 | 390 | [[package]] 391 | name = "poethepoet" 392 | version = "0.16.5" 393 | description = "A task runner that works well with poetry." 394 | optional = false 395 | python-versions = ">=3.7" 396 | groups = ["dev"] 397 | files = [ 398 | {file = "poethepoet-0.16.5-py3-none-any.whl", hash = "sha256:493d5d47b4cb0894dde6a69d14129ba39ef3f124fabda1f83ebb39bbf737a40e"}, 399 | {file = "poethepoet-0.16.5.tar.gz", hash = "sha256:3c958792ce488661ba09df67ba832a1b3141aa640236505ee60c23f4b1db4dbc"}, 400 | ] 401 | 402 | [package.dependencies] 403 | pastel = ">=0.2.1,<0.3.0" 404 | tomli = ">=1.2.2" 405 | 406 | [package.extras] 407 | poetry-plugin = ["poetry (>=1.0,<2.0)"] 408 | 409 | [[package]] 410 | name = "pre-commit" 411 | version = "3.5.0" 412 | description = "A framework for managing and maintaining multi-language pre-commit hooks." 413 | optional = false 414 | python-versions = ">=3.8" 415 | groups = ["dev"] 416 | markers = "python_full_version >= \"3.8.1\"" 417 | files = [ 418 | {file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"}, 419 | {file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"}, 420 | ] 421 | 422 | [package.dependencies] 423 | cfgv = ">=2.0.0" 424 | identify = ">=1.0.0" 425 | nodeenv = ">=0.11.1" 426 | pyyaml = ">=5.1" 427 | virtualenv = ">=20.10.0" 428 | 429 | [[package]] 430 | name = "py" 431 | version = "1.11.0" 432 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 433 | optional = false 434 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 435 | groups = ["test"] 436 | files = [ 437 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 438 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 439 | ] 440 | 441 | [[package]] 442 | name = "pyroute2" 443 | version = "0.7.12" 444 | description = "Python Netlink library" 445 | optional = false 446 | python-versions = "*" 447 | groups = ["main"] 448 | files = [ 449 | {file = "pyroute2-0.7.12-py3-none-any.whl", hash = "sha256:9df8d0fcb5fb0a724603bcfdef76ffbd287f00f69e9fb660c20a06962b24691a"}, 450 | {file = "pyroute2-0.7.12.tar.gz", hash = "sha256:54d226fc3ff2732f49bac9b26853c50c9d05be05a4d9daf09c7cf6d77301eff3"}, 451 | ] 452 | 453 | [package.dependencies] 454 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 455 | win-inet-pton = {version = "*", markers = "platform_system == \"Windows\""} 456 | 457 | [[package]] 458 | name = "pytest" 459 | version = "6.2.5" 460 | description = "pytest: simple powerful testing with Python" 461 | optional = false 462 | python-versions = ">=3.6" 463 | groups = ["test"] 464 | files = [ 465 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 466 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 467 | ] 468 | 469 | [package.dependencies] 470 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 471 | attrs = ">=19.2.0" 472 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 473 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 474 | iniconfig = "*" 475 | packaging = "*" 476 | pluggy = ">=0.12,<2.0" 477 | py = ">=1.8.2" 478 | toml = "*" 479 | 480 | [package.extras] 481 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 482 | 483 | [[package]] 484 | name = "pytest-mock" 485 | version = "3.11.1" 486 | description = "Thin-wrapper around the mock package for easier use with pytest" 487 | optional = false 488 | python-versions = ">=3.7" 489 | groups = ["test"] 490 | files = [ 491 | {file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"}, 492 | {file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"}, 493 | ] 494 | 495 | [package.dependencies] 496 | pytest = ">=5.0" 497 | 498 | [package.extras] 499 | dev = ["pre-commit", "pytest-asyncio", "tox"] 500 | 501 | [[package]] 502 | name = "pyyaml" 503 | version = "6.0.3" 504 | description = "YAML parser and emitter for Python" 505 | optional = false 506 | python-versions = ">=3.8" 507 | groups = ["dev"] 508 | markers = "python_full_version >= \"3.8.1\"" 509 | files = [ 510 | {file = "pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b"}, 511 | {file = "pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956"}, 512 | {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8"}, 513 | {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198"}, 514 | {file = "pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b"}, 515 | {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0"}, 516 | {file = "pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69"}, 517 | {file = "pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e"}, 518 | {file = "pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c"}, 519 | {file = "pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e"}, 520 | {file = "pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824"}, 521 | {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c"}, 522 | {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00"}, 523 | {file = "pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d"}, 524 | {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a"}, 525 | {file = "pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4"}, 526 | {file = "pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b"}, 527 | {file = "pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf"}, 528 | {file = "pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196"}, 529 | {file = "pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"}, 530 | {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28"}, 531 | {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c"}, 532 | {file = "pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc"}, 533 | {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e"}, 534 | {file = "pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea"}, 535 | {file = "pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5"}, 536 | {file = "pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b"}, 537 | {file = "pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd"}, 538 | {file = "pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8"}, 539 | {file = "pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1"}, 540 | {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c"}, 541 | {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5"}, 542 | {file = "pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6"}, 543 | {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6"}, 544 | {file = "pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be"}, 545 | {file = "pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26"}, 546 | {file = "pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c"}, 547 | {file = "pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb"}, 548 | {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, 549 | {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, 550 | {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, 551 | {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, 552 | {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, 553 | {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, 554 | {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, 555 | {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, 556 | {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, 557 | {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, 558 | {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, 559 | {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, 560 | {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, 561 | {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, 562 | {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, 563 | {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, 564 | {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, 565 | {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, 566 | {file = "pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da"}, 567 | {file = "pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917"}, 568 | {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9"}, 569 | {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5"}, 570 | {file = "pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a"}, 571 | {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926"}, 572 | {file = "pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7"}, 573 | {file = "pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0"}, 574 | {file = "pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007"}, 575 | {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, 576 | ] 577 | 578 | [[package]] 579 | name = "schema" 580 | version = "0.7.7" 581 | description = "Simple data validation library" 582 | optional = false 583 | python-versions = "*" 584 | groups = ["dev"] 585 | files = [ 586 | {file = "schema-0.7.7-py2.py3-none-any.whl", hash = "sha256:5d976a5b50f36e74e2157b47097b60002bd4d42e65425fcc9c9befadb4255dde"}, 587 | {file = "schema-0.7.7.tar.gz", hash = "sha256:7da553abd2958a19dc2547c388cde53398b39196175a9be59ea1caf5ab0a1807"}, 588 | ] 589 | 590 | [[package]] 591 | name = "segno" 592 | version = "1.6.6" 593 | description = "QR Code and Micro QR Code generator for Python" 594 | optional = false 595 | python-versions = ">=3.5" 596 | groups = ["main"] 597 | files = [ 598 | {file = "segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7"}, 599 | {file = "segno-1.6.6.tar.gz", hash = "sha256:e60933afc4b52137d323a4434c8340e0ce1e58cec71439e46680d4db188f11b3"}, 600 | ] 601 | 602 | [package.dependencies] 603 | importlib-metadata = {version = ">=3.6.0", markers = "python_version < \"3.10\""} 604 | 605 | [[package]] 606 | name = "tabulate" 607 | version = "0.8.10" 608 | description = "Pretty-print tabular data" 609 | optional = false 610 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 611 | groups = ["dev"] 612 | files = [ 613 | {file = "tabulate-0.8.10-py3-none-any.whl", hash = "sha256:0ba055423dbaa164b9e456abe7920c5e8ed33fcc16f6d1b2f2d152c8e1e8b4fc"}, 614 | {file = "tabulate-0.8.10.tar.gz", hash = "sha256:6c57f3f3dd7ac2782770155f3adb2db0b1a269637e42f27599925e64b114f519"}, 615 | ] 616 | 617 | [package.extras] 618 | widechars = ["wcwidth"] 619 | 620 | [[package]] 621 | name = "tbump" 622 | version = "6.11.0" 623 | description = "Bump software releases" 624 | optional = false 625 | python-versions = ">=3.7,<4.0" 626 | groups = ["dev"] 627 | files = [ 628 | {file = "tbump-6.11.0-py3-none-any.whl", hash = "sha256:6b181fe6f3ae84ce0b9af8cc2009a8bca41ded34e73f623a7413b9684f1b4526"}, 629 | {file = "tbump-6.11.0.tar.gz", hash = "sha256:385e710eedf0a8a6ff959cf1e9f3cfd17c873617132fc0ec5f629af0c355c870"}, 630 | ] 631 | 632 | [package.dependencies] 633 | cli-ui = ">=0.10.3" 634 | docopt = ">=0.6.2,<0.7.0" 635 | schema = ">=0.7.1,<0.8.0" 636 | tomlkit = ">=0.11,<0.12" 637 | 638 | [[package]] 639 | name = "toml" 640 | version = "0.10.2" 641 | description = "Python Library for Tom's Obvious, Minimal Language" 642 | optional = false 643 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 644 | groups = ["test"] 645 | files = [ 646 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 647 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 648 | ] 649 | 650 | [[package]] 651 | name = "tomli" 652 | version = "2.0.1" 653 | description = "A lil' TOML parser" 654 | optional = false 655 | python-versions = ">=3.7" 656 | groups = ["dev", "test"] 657 | files = [ 658 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 659 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 660 | ] 661 | markers = {test = "python_version < \"3.11\""} 662 | 663 | [[package]] 664 | name = "tomlkit" 665 | version = "0.11.8" 666 | description = "Style preserving TOML library" 667 | optional = false 668 | python-versions = ">=3.7" 669 | groups = ["dev"] 670 | files = [ 671 | {file = "tomlkit-0.11.8-py3-none-any.whl", hash = "sha256:8c726c4c202bdb148667835f68d68780b9a003a9ec34167b6c673b38eff2a171"}, 672 | {file = "tomlkit-0.11.8.tar.gz", hash = "sha256:9330fc7faa1db67b541b28e62018c17d20be733177d290a13b24c62d1614e0c3"}, 673 | ] 674 | 675 | [[package]] 676 | name = "typed-ast" 677 | version = "1.5.5" 678 | description = "a fork of Python 2 and 3 ast modules with type comment support" 679 | optional = false 680 | python-versions = ">=3.6" 681 | groups = ["test"] 682 | markers = "python_version == \"3.7\"" 683 | files = [ 684 | {file = "typed_ast-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4bc1efe0ce3ffb74784e06460f01a223ac1f6ab31c6bc0376a21184bf5aabe3b"}, 685 | {file = "typed_ast-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f7a8c46a8b333f71abd61d7ab9255440d4a588f34a21f126bbfc95f6049e686"}, 686 | {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:597fc66b4162f959ee6a96b978c0435bd63791e31e4f410622d19f1686d5e769"}, 687 | {file = "typed_ast-1.5.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d41b7a686ce653e06c2609075d397ebd5b969d821b9797d029fccd71fdec8e04"}, 688 | {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:5fe83a9a44c4ce67c796a1b466c270c1272e176603d5e06f6afbc101a572859d"}, 689 | {file = "typed_ast-1.5.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d5c0c112a74c0e5db2c75882a0adf3133adedcdbfd8cf7c9d6ed77365ab90a1d"}, 690 | {file = "typed_ast-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:e1a976ed4cc2d71bb073e1b2a250892a6e968ff02aa14c1f40eba4f365ffec02"}, 691 | {file = "typed_ast-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c631da9710271cb67b08bd3f3813b7af7f4c69c319b75475436fcab8c3d21bee"}, 692 | {file = "typed_ast-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b445c2abfecab89a932b20bd8261488d574591173d07827c1eda32c457358b18"}, 693 | {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc95ffaaab2be3b25eb938779e43f513e0e538a84dd14a5d844b8f2932593d88"}, 694 | {file = "typed_ast-1.5.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61443214d9b4c660dcf4b5307f15c12cb30bdfe9588ce6158f4a005baeb167b2"}, 695 | {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6eb936d107e4d474940469e8ec5b380c9b329b5f08b78282d46baeebd3692dc9"}, 696 | {file = "typed_ast-1.5.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e48bf27022897577d8479eaed64701ecaf0467182448bd95759883300ca818c8"}, 697 | {file = "typed_ast-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:83509f9324011c9a39faaef0922c6f720f9623afe3fe220b6d0b15638247206b"}, 698 | {file = "typed_ast-1.5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:44f214394fc1af23ca6d4e9e744804d890045d1643dd7e8229951e0ef39429b5"}, 699 | {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:118c1ce46ce58fda78503eae14b7664163aa735b620b64b5b725453696f2a35c"}, 700 | {file = "typed_ast-1.5.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4919b808efa61101456e87f2d4c75b228f4e52618621c77f1ddcaae15904fa"}, 701 | {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:fc2b8c4e1bc5cd96c1a823a885e6b158f8451cf6f5530e1829390b4d27d0807f"}, 702 | {file = "typed_ast-1.5.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:16f7313e0a08c7de57f2998c85e2a69a642e97cb32f87eb65fbfe88381a5e44d"}, 703 | {file = "typed_ast-1.5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:2b946ef8c04f77230489f75b4b5a4a6f24c078be4aed241cfabe9cbf4156e7e5"}, 704 | {file = "typed_ast-1.5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2188bc33d85951ea4ddad55d2b35598b2709d122c11c75cffd529fbc9965508e"}, 705 | {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0635900d16ae133cab3b26c607586131269f88266954eb04ec31535c9a12ef1e"}, 706 | {file = "typed_ast-1.5.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:57bfc3cf35a0f2fdf0a88a3044aafaec1d2f24d8ae8cd87c4f58d615fb5b6311"}, 707 | {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:fe58ef6a764de7b4b36edfc8592641f56e69b7163bba9f9c8089838ee596bfb2"}, 708 | {file = "typed_ast-1.5.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d09d930c2d1d621f717bb217bf1fe2584616febb5138d9b3e8cdd26506c3f6d4"}, 709 | {file = "typed_ast-1.5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:d40c10326893ecab8a80a53039164a224984339b2c32a6baf55ecbd5b1df6431"}, 710 | {file = "typed_ast-1.5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:fd946abf3c31fb50eee07451a6aedbfff912fcd13cf357363f5b4e834cc5e71a"}, 711 | {file = "typed_ast-1.5.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ed4a1a42df8a3dfb6b40c3d2de109e935949f2f66b19703eafade03173f8f437"}, 712 | {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:045f9930a1550d9352464e5149710d56a2aed23a2ffe78946478f7b5416f1ede"}, 713 | {file = "typed_ast-1.5.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:381eed9c95484ceef5ced626355fdc0765ab51d8553fec08661dce654a935db4"}, 714 | {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bfd39a41c0ef6f31684daff53befddae608f9daf6957140228a08e51f312d7e6"}, 715 | {file = "typed_ast-1.5.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8c524eb3024edcc04e288db9541fe1f438f82d281e591c548903d5b77ad1ddd4"}, 716 | {file = "typed_ast-1.5.5-cp38-cp38-win_amd64.whl", hash = "sha256:7f58fabdde8dcbe764cef5e1a7fcb440f2463c1bbbec1cf2a86ca7bc1f95184b"}, 717 | {file = "typed_ast-1.5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:042eb665ff6bf020dd2243307d11ed626306b82812aba21836096d229fdc6a10"}, 718 | {file = "typed_ast-1.5.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:622e4a006472b05cf6ef7f9f2636edc51bda670b7bbffa18d26b255269d3d814"}, 719 | {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1efebbbf4604ad1283e963e8915daa240cb4bf5067053cf2f0baadc4d4fb51b8"}, 720 | {file = "typed_ast-1.5.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0aefdd66f1784c58f65b502b6cf8b121544680456d1cebbd300c2c813899274"}, 721 | {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:48074261a842acf825af1968cd912f6f21357316080ebaca5f19abbb11690c8a"}, 722 | {file = "typed_ast-1.5.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:429ae404f69dc94b9361bb62291885894b7c6fb4640d561179548c849f8492ba"}, 723 | {file = "typed_ast-1.5.5-cp39-cp39-win_amd64.whl", hash = "sha256:335f22ccb244da2b5c296e6f96b06ee9bed46526db0de38d2f0e5a6597b81155"}, 724 | {file = "typed_ast-1.5.5.tar.gz", hash = "sha256:94282f7a354f36ef5dbce0ef3467ebf6a258e370ab33d5b40c249fa996e590dd"}, 725 | ] 726 | 727 | [[package]] 728 | name = "typing-extensions" 729 | version = "4.7.1" 730 | description = "Backported and Experimental Type Hints for Python 3.7+" 731 | optional = false 732 | python-versions = ">=3.7" 733 | groups = ["main", "dev", "test"] 734 | files = [ 735 | {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, 736 | {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, 737 | ] 738 | markers = {main = "python_version == \"3.7\"", dev = "python_version >= \"3.8\" and python_version < \"3.11\""} 739 | 740 | [[package]] 741 | name = "unidecode" 742 | version = "1.4.0" 743 | description = "ASCII transliterations of Unicode text" 744 | optional = false 745 | python-versions = ">=3.7" 746 | groups = ["dev"] 747 | files = [ 748 | {file = "Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021"}, 749 | {file = "Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23"}, 750 | ] 751 | 752 | [[package]] 753 | name = "virtualenv" 754 | version = "20.33.1" 755 | description = "Virtual Python Environment builder" 756 | optional = false 757 | python-versions = ">=3.8" 758 | groups = ["dev"] 759 | markers = "python_full_version >= \"3.8.1\"" 760 | files = [ 761 | {file = "virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67"}, 762 | {file = "virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8"}, 763 | ] 764 | 765 | [package.dependencies] 766 | distlib = ">=0.3.7,<1" 767 | filelock = ">=3.12.2,<4" 768 | platformdirs = ">=3.9.1,<5" 769 | 770 | [package.extras] 771 | docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] 772 | test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] 773 | 774 | [[package]] 775 | name = "win-inet-pton" 776 | version = "1.1.0" 777 | description = "Native inet_pton and inet_ntop implementation for Python on Windows (with ctypes)." 778 | optional = false 779 | python-versions = "*" 780 | groups = ["main"] 781 | markers = "platform_system == \"Windows\"" 782 | files = [ 783 | {file = "win_inet_pton-1.1.0-py2.py3-none-any.whl", hash = "sha256:eaf0193cbe7152ac313598a0da7313fb479f769343c0c16c5308f64887dc885b"}, 784 | {file = "win_inet_pton-1.1.0.tar.gz", hash = "sha256:dd03d942c0d3e2b1cf8bab511844546dfa5f74cb61b241699fa379ad707dea4f"}, 785 | ] 786 | 787 | [[package]] 788 | name = "zipp" 789 | version = "3.15.0" 790 | description = "Backport of pathlib-compatible object wrapper for zip files" 791 | optional = false 792 | python-versions = ">=3.7" 793 | groups = ["main", "test"] 794 | files = [ 795 | {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"}, 796 | {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"}, 797 | ] 798 | markers = {main = "python_version < \"3.10\"", test = "python_version == \"3.7\""} 799 | 800 | [package.extras] 801 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 802 | testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8 ; python_version < \"3.12\"", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] 803 | 804 | [metadata] 805 | lock-version = "2.1" 806 | python-versions = "^3.7" 807 | content-hash = "8185029a85b7c3120993771ec037bfac282f2961387639173f82a8defacec9a0" 808 | --------------------------------------------------------------------------------