├── tests ├── __init__.py └── test_manual.py ├── docs ├── index.md ├── about │ ├── license.md │ ├── changelog.md │ └── contributing.md ├── api │ ├── enum.md │ ├── network.md │ └── protocol.md └── requirements.txt ├── skydance ├── network │ ├── __init__.py │ ├── buffer.py │ ├── session.py │ └── discovery.py ├── tests │ ├── __init__.py │ ├── network │ │ ├── __init__.py │ │ ├── test_discovery.py │ │ ├── test_buffer.py │ │ └── test_session.py │ └── test_protocol.py ├── __init__.py ├── enum.py └── protocol.py ├── .gitattributes ├── .mypy.ini ├── .verchew.ini ├── .scrutinizer.yml ├── .isort.cfg ├── pytest.ini ├── bin ├── open ├── checksum └── verchew ├── .pydocstyle.ini ├── .gitignore ├── .appveyor.yml ├── .github └── workflows │ └── ci.yaml ├── CHANGELOG.md ├── LICENSE.md ├── mkdocs.yml ├── CONTRIBUTING.md ├── README.md ├── pyproject.toml ├── scent.py ├── Makefile └── poetry.lock /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ../README.md -------------------------------------------------------------------------------- /skydance/network/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /skydance/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/about/license.md: -------------------------------------------------------------------------------- 1 | ../../LICENSE.md -------------------------------------------------------------------------------- /skydance/tests/network/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/about/changelog.md: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.md -------------------------------------------------------------------------------- /docs/about/contributing.md: -------------------------------------------------------------------------------- 1 | ../../CONTRIBUTING.md -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | CHANGELOG.md merge=union 3 | -------------------------------------------------------------------------------- /docs/api/enum.md: -------------------------------------------------------------------------------- 1 | # Enums 2 | 3 | ::: skydance.enum.ZoneType 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs==1.2.3 2 | mkdocs-material==7.3.3 3 | mkdocs-material-extensions==1.0.1 4 | mkdocstrings==0.13.6 5 | Pygments==2.15.0 6 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | 3 | ignore_missing_imports = true 4 | no_implicit_optional = true 5 | check_untyped_defs = true 6 | 7 | cache_dir = .cache/mypy/ 8 | -------------------------------------------------------------------------------- /.verchew.ini: -------------------------------------------------------------------------------- 1 | [Make] 2 | 3 | cli = make 4 | version = GNU Make 5 | 6 | [Python] 7 | 8 | cli = python 9 | version = 3.9 10 | 11 | [Poetry] 12 | 13 | cli = poetry 14 | version = 2 15 | -------------------------------------------------------------------------------- /skydance/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import DistributionNotFound, get_distribution 2 | 3 | 4 | try: 5 | __version__ = get_distribution("skydance").version 6 | except DistributionNotFound: 7 | __version__ = "(local)" 8 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | tests: 3 | override: 4 | - py-scrutinizer-run 5 | checks: 6 | python: 7 | code_rating: true 8 | duplicate_code: true 9 | filter: 10 | excluded_paths: 11 | - "*/tests/*" 12 | -------------------------------------------------------------------------------- /skydance/tests/network/test_discovery.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from skydance.network.discovery import DiscoveryProtocol 4 | 5 | 6 | def test_misuse(): 7 | protocol = DiscoveryProtocol() 8 | with pytest.raises(expected_exception=ValueError): 9 | protocol.send_discovery_request() 10 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | 3 | multi_line_output = 3 4 | 5 | known_standard_library = dataclasses,typing_extensions 6 | known_third_party = click,log 7 | known_first_party = skydance 8 | 9 | combine_as_imports = true 10 | force_grid_wrap = false 11 | include_trailing_comma = true 12 | 13 | lines_after_imports = 2 14 | line_length = 88 15 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | asyncio_mode = auto 3 | log_cli = True 4 | log_cli_level = INFO 5 | 6 | addopts = 7 | --strict-markers 8 | --pdbcls=tests:Debugger 9 | 10 | -r sxX 11 | 12 | --cov-report=html 13 | --cov-report=term-missing:skip-covered 14 | --no-cov-on-fail 15 | 16 | cache_dir = .cache 17 | 18 | markers = 19 | -------------------------------------------------------------------------------- /bin/open: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | 7 | 8 | COMMANDS = { 9 | 'linux': "open", 10 | 'win32': "cmd /c start", 11 | 'cygwin': "cygstart", 12 | 'darwin': "open", 13 | } 14 | 15 | 16 | def run(path): 17 | command = COMMANDS.get(sys.platform, "open") 18 | os.system(command + ' ' + path) 19 | 20 | 21 | if __name__ == '__main__': 22 | run(sys.argv[-1]) 23 | -------------------------------------------------------------------------------- /docs/api/network.md: -------------------------------------------------------------------------------- 1 | # Network 2 | 3 | ::: skydance.network.session.Session 4 | rendering: 5 | heading_level: 2 6 | 7 | ## Discovery 8 | 9 | ::: skydance.network.discovery.discover_ips_by_mac 10 | 11 | ::: skydance.network.discovery.DiscoveryProtocol 12 | selection: 13 | members: 14 | - send_discovery_request 15 | - get_discovery_result 16 | 17 | 18 | ::: skydance.network.buffer.Buffer 19 | rendering: 20 | heading_level: 2 21 | -------------------------------------------------------------------------------- /bin/checksum: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import hashlib 5 | import sys 6 | 7 | 8 | def run(paths): 9 | sha = hashlib.sha1() 10 | 11 | for path in paths: 12 | try: 13 | with open(path, 'rb') as f: 14 | for chunk in iter(lambda: f.read(4096), b''): 15 | sha.update(chunk) 16 | except IOError: 17 | sha.update(path.encode()) 18 | 19 | print(sha.hexdigest()) 20 | 21 | 22 | if __name__ == '__main__': 23 | run(sys.argv[1:]) 24 | -------------------------------------------------------------------------------- /.pydocstyle.ini: -------------------------------------------------------------------------------- 1 | [pydocstyle] 2 | 3 | # D211: No blank lines allowed before class docstring 4 | add_select = D211 5 | 6 | # D100: Missing docstring in public module 7 | # D101: Missing docstring in public class 8 | # D102: Missing docstring in public method 9 | # D103: Missing docstring in public function 10 | # D104: Missing docstring in public package 11 | # D105: Missing docstring in magic method 12 | # D107: Missing docstring in __init__ 13 | # D202: No blank lines allowed after function docstring 14 | add_ignore = D100,D101,D102,D103,D104,D105,D107,D202 15 | -------------------------------------------------------------------------------- /skydance/enum.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class ZoneType(Enum): 5 | """Zone types which are set manually using Skydance official application.""" 6 | 7 | Switch = 0x01 8 | """Can only be switched on-off.""" 9 | 10 | Dimmer = 0x11 11 | """Its brightness can be adjusted.""" 12 | 13 | CCT = 0x21 14 | """Its brightness and temperature can be adjusted.""" 15 | 16 | RGB = 0x31 17 | """Its brightness and RGB color values can be adjusted.""" 18 | 19 | RGBW = 0x41 20 | """Its brightness and RGBW color values can be adjusted.""" 21 | 22 | RGBCCT = 0x51 23 | """Its brightness, RGBW color values and temperature can be adjusted.""" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary Python files 2 | *.pyc 3 | *.egg-info 4 | __pycache__ 5 | .ipynb_checkpoints 6 | setup.py 7 | pip-wheel-metadata/ 8 | 9 | # Temporary OS files 10 | Icon* 11 | 12 | # Temporary virtual environment files 13 | /.cache/ 14 | /.venv/ 15 | 16 | # Temporary server files 17 | .env 18 | *.pid 19 | 20 | # Generated documentation 21 | /docs/gen/ 22 | /docs/apidocs/ 23 | /site/ 24 | /*.html 25 | /docs/*.png 26 | 27 | # Google Drive 28 | *.gdoc 29 | *.gsheet 30 | *.gslides 31 | *.gdraw 32 | 33 | # Testing and coverage results 34 | /.coverage 35 | /.coverage.* 36 | /htmlcov/ 37 | 38 | # Build and release directories 39 | /build/ 40 | /dist/ 41 | *.spec 42 | 43 | # Sublime Text 44 | *.sublime-workspace 45 | 46 | # Eclipse 47 | .settings 48 | 49 | 50 | # custom 51 | packet_captures/ 52 | -------------------------------------------------------------------------------- /.appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | global: 3 | RANDOM_SEED: 0 4 | matrix: 5 | - PYTHON_MAJOR: 3 6 | PYTHON_MINOR: 8 7 | 8 | cache: 9 | - .venv -> poetry.lock 10 | 11 | install: 12 | # Add Make and Python to the PATH 13 | - copy C:\MinGW\bin\mingw32-make.exe C:\MinGW\bin\make.exe 14 | - set PATH=%PATH%;C:\MinGW\bin 15 | - set PATH=C:\Python%PYTHON_MAJOR%%PYTHON_MINOR%;%PATH% 16 | - set PATH=C:\Python%PYTHON_MAJOR%%PYTHON_MINOR%\Scripts;%PATH% 17 | # Install system dependencies 18 | - curl -sSL https://raw.githubusercontent.com/sdispater/poetry/master/get-poetry.py | python 19 | - set PATH=%USERPROFILE%\.poetry\bin;%PATH% 20 | - make doctor 21 | # Install project dependencies 22 | - make install 23 | 24 | build: off 25 | 26 | test_script: 27 | - make check 28 | - make test 29 | -------------------------------------------------------------------------------- /docs/api/protocol.md: -------------------------------------------------------------------------------- 1 | # Protocol 2 | 3 | ## Constants 4 | 5 | ::: skydance.protocol.PORT 6 | ::: skydance.protocol.HEAD 7 | ::: skydance.protocol.TAIL 8 | 9 | ::: skydance.protocol.State 10 | rendering: 11 | heading_level: 2 12 | 13 | ## Commands 14 | 15 | ::: skydance.protocol.Command 16 | ::: skydance.protocol.PingCommand 17 | ::: skydance.protocol.ZoneCommand 18 | ::: skydance.protocol.PowerCommand 19 | ::: skydance.protocol.PowerOnCommand 20 | ::: skydance.protocol.PowerOffCommand 21 | ::: skydance.protocol.MasterPowerCommand 22 | ::: skydance.protocol.MasterPowerOnCommand 23 | ::: skydance.protocol.MasterPowerOffCommand 24 | ::: skydance.protocol.BrightnessCommand 25 | ::: skydance.protocol.TemperatureCommand 26 | ::: skydance.protocol.RGBWCommand 27 | ::: skydance.protocol.GetNumberOfZonesCommand 28 | ::: skydance.protocol.GetZoneInfoCommand 29 | 30 | ## Responses 31 | 32 | ::: skydance.protocol.Response 33 | ::: skydance.protocol.GetNumberOfZonesResponse 34 | ::: skydance.protocol.GetZoneInfoResponse 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: 3.9 23 | 24 | - name: Install Poetry 25 | uses: snok/install-poetry@v1 26 | 27 | - name: Cache dependencies 28 | uses: actions/cache@v4 29 | with: 30 | path: | 31 | ~/.cache/pip 32 | ~/.poetry 33 | key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }} 34 | restore-keys: | 35 | ${{ runner.os }}-poetry- 36 | 37 | - name: Install dependencies 38 | run: make install 39 | 40 | - name: Run checks 41 | run: make check 42 | 43 | - name: Run tests 44 | run: make test 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.1 (2024-09-27) 2 | 3 | - Fix parsing of `GetNumberOfZonesResponse` (see [#17](https://github.com/tomasbedrich/skydance/pull/17)) 4 | 5 | # 1.0.0 (2021-10-17) 6 | 7 | - Add support for RGBW commands. 8 | - Add zone type detection and parsing. 9 | 10 | **BREAKING CHANGES:** 11 | 12 | - `skydance.protocol.GetZoneNameCommand` renamed to `skydance.protocol.GetZoneInfoCommand`. 13 | - `skydance.protocol.GetZoneNameResponse` renamed to `skydance.protocol.GetZoneInfoResponse`. 14 | 15 | # 0.1.2 (2021-01-17) 16 | 17 | - Suppress network errors during session closing. 18 | 19 | # 0.1.1 (2020-12-28) 20 | 21 | - Fix byte representation of zone IDs higher than 2. 22 | 23 | # 0.1.0 (2020-12-27) 24 | 25 | - Implement `Session` with connection re-creation in case of its failure. 26 | - Implement Skydance discovery protocol. 27 | - Reverse-engineer more commands. 28 | - Make the library Sans I/O (separate IO and protocol logic). 29 | - Write basic documentation. 30 | - Streamline tests and remove redundant ones. 31 | 32 | 33 | # 0.0.1 (2020-10-13) 34 | 35 | - First public release. 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | **The MIT License (MIT)** 2 | 3 | Copyright © 2020, Tomas Bedrich 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Skydance 2 | site_description: A library for communication with Skydance Wi-Fi relays. 3 | site_author: Tomas Bedrich 4 | copyright: Copyright © 2020 Tomas Bedrich 5 | 6 | repo_url: https://github.com/tomasbedrich/skydance 7 | edit_uri: https://github.com/tomasbedrich/skydance/edit/master/docs 8 | 9 | theme: 10 | name: material 11 | palette: 12 | scheme: preference 13 | primary: pink 14 | accent: pink 15 | 16 | markdown_extensions: 17 | - codehilite 18 | - admonition 19 | - toc: 20 | permalink: True 21 | 22 | plugins: 23 | - mkdocstrings: 24 | default_handler: python 25 | handlers: 26 | python: 27 | rendering: 28 | show_source: false 29 | show_root_toc_entry: false 30 | show_root_heading: true 31 | heading_level: 3 32 | watch: 33 | - skydance 34 | 35 | nav: 36 | - Home: index.md 37 | - API: 38 | - Protocol: api/protocol.md 39 | - Network: api/network.md 40 | - Enums: api/enum.md 41 | - About: 42 | - Release Notes: about/changelog.md 43 | - Contributing: about/contributing.md 44 | - License: about/license.md 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Setup 2 | 3 | ## Requirements 4 | 5 | * Make: 6 | * macOS: `$ xcode-select --install` 7 | * Linux: [https://www.gnu.org/software/make](https://www.gnu.org/software/make) 8 | * Windows: [https://mingw.org/download/installer](https://mingw.org/download/installer) 9 | * Python: `$ pyenv install` 10 | * Poetry: [https://poetry.eustace.io/docs/#installation](https://poetry.eustace.io/docs/#installation) 11 | 12 | To confirm these system dependencies are configured correctly: 13 | 14 | ```text 15 | $ make doctor 16 | ``` 17 | 18 | ## Installation 19 | 20 | Install project dependencies into a virtual environment: 21 | 22 | ```text 23 | $ make install 24 | ``` 25 | 26 | # Development Tasks 27 | 28 | ## Manual 29 | 30 | Run the tests: 31 | 32 | ```text 33 | $ make test 34 | ``` 35 | 36 | Run static analysis: 37 | 38 | ```text 39 | $ make check 40 | ``` 41 | 42 | Build the documentation: 43 | 44 | ```text 45 | $ make docs 46 | ``` 47 | 48 | ## Automatic 49 | 50 | Keep all of the above tasks running on change: 51 | 52 | ```text 53 | $ make watch 54 | ``` 55 | 56 | > In order to have OS X notifications, `brew install terminal-notifier`. 57 | 58 | # Continuous Integration 59 | 60 | The CI server will report overall build status: 61 | 62 | ```text 63 | $ make ci 64 | ``` 65 | 66 | # Release Tasks 67 | 68 | Release to PyPI: 69 | 70 | ```text 71 | $ make upload 72 | ``` 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | A library for communication with Skydance Wi-Fi relays. 4 | 5 | Buy Me A Coffee 6 | 7 | [![PyPI Version](https://img.shields.io/pypi/v/skydance.svg)](https://pypi.org/project/skydance) 8 | [![PyPI License](https://img.shields.io/pypi/l/skydance.svg)](https://pypi.org/project/skydance) 9 | 10 | The original product is [a Wi-Fi to RF gateway](http://www.iskydance.com/index.php?c=product_show&a=index&id=810) manufactured by Skydance Co. China. 11 | 12 | Sometimes, it is re-branded under different names. For example, in the Czech Republic, [it is sold](https://www.t-led.cz/p/ovladac-wifi-dimled-69381) as "dimLED" system. 13 | 14 | This aim of this library is to roughly cover capabilities of [the official SkySmart Android application](https://play.google.com/store/apps/details?id=com.lxit.wifirelay&hl=cs&gl=US). 15 | 16 | # Setup 17 | 18 | ## Requirements 19 | 20 | * Python 3.9+ 21 | 22 | ## Installation 23 | 24 | ```text 25 | $ pip install skydance 26 | ``` 27 | 28 | # Usage 29 | 30 | The protocol implementation is [Sans I/O](https://sans-io.readthedocs.io/). 31 | You must create the connection and send the byte payloads on your own. 32 | There are some helpers in `skydance.network` built on top of Python's asyncio which helps you to wrap the I/O. 33 | 34 | TODO - In meantime, please see `test/test_manual.py`. 35 | 36 | # Links 37 | - [Home Assistant reverse engineering forum thread](https://community.home-assistant.io/t/skydance-2-4g-rf/99399) 38 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | 3 | name = "skydance" 4 | version = "2.0.0" 5 | description = "A library for communication with Skydance Wi-Fi relays." 6 | 7 | license = "MIT" 8 | 9 | authors = ["Tomas Bedrich "] 10 | 11 | readme = "README.md" 12 | 13 | homepage = "https://pypi.org/project/skydance" 14 | documentation = "https://skydance.readthedocs.io" 15 | repository = "https://github.com/tomasbedrich/skydance" 16 | 17 | keywords = [ 18 | ] 19 | classifiers = [ 20 | "Development Status :: 1 - Planning", 21 | "Natural Language :: English", 22 | "Operating System :: OS Independent", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3", 25 | "Topic :: System :: Networking", 26 | "Topic :: System :: Hardware", 27 | "Topic :: Home Automation", 28 | ] 29 | 30 | [tool.poetry.dependencies] 31 | 32 | python = "^3.9" 33 | 34 | 35 | [tool.poetry.dev-dependencies] 36 | 37 | # Formatters 38 | black = "^24.8" 39 | isort = "^5.6.3" 40 | 41 | # Linters 42 | mypy = "*" 43 | pydocstyle = "*" 44 | 45 | # Testing 46 | pytest = "^6.1.1" 47 | pytest-cov = "*" 48 | pytest-describe = { git = "https://github.com/pytest-dev/pytest-describe", rev = "453aa9045b265e313f356f1492d8991c02a6aea6" } # use 2.0 when released 49 | pytest-expecter = "^2.1" 50 | pytest-random = "*" 51 | pytest-asyncio = "^0.14.0" 52 | 53 | # Reports 54 | coveragespace = "^3.1.1" 55 | 56 | # Documentation 57 | mkdocs = "^1.1.2" 58 | mkdocs-material = "*" 59 | mkdocstrings = "*" 60 | pygments = "^2.15.0" 61 | 62 | # Tooling 63 | sniffer = "*" 64 | MacFSEvents = { version = "*", platform = "darwin" } 65 | pync = { version = "*", platform = "darwin" } 66 | 67 | [tool.black] 68 | 69 | target-version = ["py36", "py37", "py38"] 70 | 71 | [build-system] 72 | 73 | requires = ["poetry-core>=1.0.0"] 74 | build-backend = "poetry.core.masonry.api" 75 | -------------------------------------------------------------------------------- /skydance/tests/network/test_buffer.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from skydance.network.buffer import Buffer 4 | 5 | 6 | def test_init(): 7 | Buffer(bytes([0, 0])) 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "tail", 12 | [(), (0,), (0, 0, 0)], 13 | ) 14 | def test_invalid_tail(tail): 15 | with pytest.raises(expected_exception=ValueError): 16 | Buffer(bytes(tail)) 17 | 18 | 19 | def test_feed(): 20 | buffer = Buffer(bytes([0, 0])) 21 | assert not buffer.is_message_ready 22 | buffer.feed(bytes([1, 2, 3, 0, 0])) 23 | assert buffer.is_message_ready 24 | assert buffer.get_message() == bytes([1, 2, 3, 0, 0]) 25 | 26 | 27 | def test_feed_shorter_than_tail(): 28 | buffer = Buffer(bytes([0, 0])) 29 | buffer.feed(bytes([1])) 30 | buffer.feed(bytes([2])) 31 | buffer.feed(bytes([3])) 32 | buffer.feed(bytes([0])) 33 | assert not buffer.is_message_ready 34 | buffer.feed(bytes([0])) 35 | assert buffer.is_message_ready 36 | assert buffer.get_message() == bytes([1, 2, 3, 0, 0]) 37 | 38 | 39 | def test_feed_tail_together(): 40 | buffer = Buffer(bytes([0, 0])) 41 | buffer.feed(bytes([1, 2, 3])) 42 | assert not buffer.is_message_ready 43 | buffer.feed(bytes([0, 0])) 44 | assert buffer.is_message_ready 45 | assert buffer.get_message() == bytes([1, 2, 3, 0, 0]) 46 | 47 | 48 | def test_feed_tail_split(): 49 | buffer = Buffer(bytes([0, 0])) 50 | buffer.feed(bytes([1, 2, 3, 0])) 51 | assert not buffer.is_message_ready 52 | buffer.feed(bytes([0])) 53 | assert buffer.is_message_ready 54 | assert buffer.get_message() == bytes([1, 2, 3, 0, 0]) 55 | 56 | 57 | def test_feed_get_message_incomplete(): 58 | buffer = Buffer(bytes([0, 0])) 59 | buffer.feed(bytes([1, 2, 3, 0])) 60 | with pytest.raises(expected_exception=ValueError): 61 | assert buffer.get_message() 62 | 63 | 64 | def test_reset(): 65 | buffer = Buffer(bytes([0, 0])) 66 | buffer.feed(bytes([5, 5, 5])) 67 | buffer.reset() 68 | buffer.feed(bytes([1, 2, 3, 0, 0])) 69 | assert buffer.get_message() == bytes([1, 2, 3, 0, 0]) 70 | -------------------------------------------------------------------------------- /skydance/tests/network/test_session.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import AsyncMock, Mock, patch 3 | 4 | from skydance.network.session import Session 5 | 6 | 7 | @pytest.mark.asyncio 8 | @patch("asyncio.open_connection") 9 | async def test_read_fail(open_connection_mock): 10 | """Simulate simple read failure.""" 11 | fake_reader, fake_writer = AsyncMock(), AsyncMock() 12 | open_connection_mock.return_value = fake_reader, fake_writer 13 | fake_reader.read = AsyncMock( 14 | side_effect=[ConnectionResetError(), ConnectionAbortedError(), bytes([1, 2, 3])] 15 | ) 16 | fake_writer.close = Mock() 17 | async with Session("127.0.0.1", 123) as session: 18 | res = await session.read() 19 | assert fake_reader.read.call_count == 3 20 | assert res == bytes([1, 2, 3]) 21 | 22 | 23 | @pytest.mark.asyncio 24 | @patch("asyncio.open_connection") 25 | async def test_write_fail(open_connection_mock): 26 | """Simulate simple write failure.""" 27 | fake_reader, fake_writer = AsyncMock(), AsyncMock() 28 | open_connection_mock.return_value = fake_reader, fake_writer 29 | fake_writer.write = Mock( 30 | side_effect=[ConnectionResetError(), ConnectionAbortedError(), None] 31 | ) 32 | fake_writer.close = Mock() 33 | async with Session("127.0.0.1", 123) as session: 34 | await session.write(bytes([0])) 35 | assert fake_writer.write.call_count == 3 36 | assert fake_writer.drain.call_count == 1 37 | 38 | 39 | @pytest.mark.asyncio 40 | @patch("asyncio.open_connection") 41 | async def test_drain_and_wait_closed_fail(open_connection_mock): 42 | """ 43 | Simulate complex connection failure. 44 | 45 | Fail during writer.drain() + simulate damaged pipe during connection closing. 46 | """ 47 | fake_reader, fake_writer = AsyncMock(), AsyncMock() 48 | open_connection_mock.return_value = fake_reader, fake_writer 49 | fake_writer.write = Mock() 50 | fake_writer.drain = AsyncMock(side_effect=[ConnectionResetError(), None]) 51 | fake_writer.close = Mock() 52 | fake_writer.wait_closed = AsyncMock( 53 | side_effect=[BrokenPipeError(), TimeoutError(), None] 54 | ) 55 | async with Session("127.0.0.1", 123) as session: 56 | await session.write(bytes([0])) 57 | assert fake_writer.write.call_count == 2 58 | assert fake_writer.drain.call_count == 2 59 | assert fake_writer.close.call_count == 1 60 | assert fake_writer.wait_closed.call_count == 1 61 | 62 | 63 | @pytest.mark.asyncio 64 | async def test_close_unopened(): 65 | s = Session("127.0.0.1", 123) 66 | await s.close() 67 | -------------------------------------------------------------------------------- /scent.py: -------------------------------------------------------------------------------- 1 | """Configuration file for sniffer.""" 2 | 3 | import time 4 | import subprocess 5 | 6 | from sniffer.api import select_runnable, file_validator, runnable 7 | try: 8 | from pync import Notifier 9 | except ImportError: 10 | notify = None 11 | else: 12 | notify = Notifier.notify 13 | 14 | 15 | watch_paths = ["skydance", "tests"] 16 | 17 | 18 | class Options: 19 | group = int(time.time()) # unique per run 20 | show_coverage = False 21 | rerun_args = None 22 | 23 | targets = [ 24 | (('make', 'test-unit', 'DISABLE_COVERAGE=true'), "Unit Tests", True), 25 | (('make', 'test-all'), "Integration Tests", False), 26 | (('make', 'check'), "Static Analysis", True), 27 | (('make', 'docs'), None, True), 28 | ] 29 | 30 | 31 | @select_runnable('run_targets') 32 | @file_validator 33 | def python_files(filename): 34 | return filename.endswith('.py') and '.py.' not in filename 35 | 36 | 37 | @select_runnable('run_targets') 38 | @file_validator 39 | def html_files(filename): 40 | return filename.split('.')[-1] in ['html', 'css', 'js'] 41 | 42 | 43 | @runnable 44 | def run_targets(*args): 45 | """Run targets for Python.""" 46 | Options.show_coverage = 'coverage' in args 47 | 48 | count = 0 49 | for count, (command, title, retry) in enumerate(Options.targets, start=1): 50 | 51 | success = call(command, title, retry) 52 | if not success: 53 | message = "✅ " * (count - 1) + "❌" 54 | show_notification(message, title) 55 | 56 | return False 57 | 58 | message = "✅ " * count 59 | title = "All Targets" 60 | show_notification(message, title) 61 | show_coverage() 62 | 63 | return True 64 | 65 | 66 | def call(command, title, retry): 67 | """Run a command-line program and display the result.""" 68 | if Options.rerun_args: 69 | command, title, retry = Options.rerun_args 70 | Options.rerun_args = None 71 | success = call(command, title, retry) 72 | if not success: 73 | return False 74 | 75 | print("") 76 | print("$ %s" % ' '.join(command)) 77 | failure = subprocess.call(command) 78 | 79 | if failure and retry: 80 | Options.rerun_args = command, title, retry 81 | 82 | return not failure 83 | 84 | 85 | def show_notification(message, title): 86 | """Show a user notification.""" 87 | if notify and title: 88 | notify(message, title=title, group=Options.group) 89 | 90 | 91 | def show_coverage(): 92 | """Launch the coverage report.""" 93 | if Options.show_coverage: 94 | subprocess.call(['make', 'read-coverage']) 95 | 96 | Options.show_coverage = False 97 | -------------------------------------------------------------------------------- /skydance/network/buffer.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from typing import Sequence 3 | 4 | 5 | # TODO is this reimplementing https://docs.python.org/3/library/asyncio-protocol.html#asyncio.BufferedProtocol.buffer_updated ? 6 | 7 | 8 | class Buffer: 9 | """ 10 | A buffer which allows feeding chunks of messages and reading them out complete. 11 | 12 | It is specificaly tailored for: 13 | 14 | - Protocols sending byte messages ending with pre-defined tail sequence. 15 | - Tail sequence length must be 2 bytes. 16 | """ 17 | 18 | _TAIL: bytes 19 | _TAIL_SEQ: Sequence[int] 20 | _buffer: deque 21 | _buffered_messages: int 22 | 23 | def __init__(self, tail: bytes): 24 | """ 25 | Create a Buffer. 26 | 27 | Args: 28 | tail: Tail byte sequence. 29 | """ 30 | if len(tail) != 2: 31 | raise ValueError( 32 | "This buffer class supports only protocols with `len(tail) == 2`." 33 | ) 34 | self._TAIL = tail 35 | self._TAIL_SEQ = tuple(tail) # this is handy for implementation 36 | self._buffer = deque() 37 | self._buffered_messages = 0 38 | 39 | def reset(self): 40 | """Clear state without a need to create a new one.""" 41 | self._buffer.clear() 42 | self._buffered_messages = 0 43 | 44 | @property 45 | def is_message_ready(self): 46 | """Return whether at least one message is ready to read.""" 47 | return self._buffered_messages > 0 48 | 49 | def feed(self, chunk: bytes): 50 | """ 51 | Feed byte chunk into a buffer. 52 | 53 | Update count of messages contained in the buffer. 54 | 55 | Args: 56 | chunk: Byte chunk of any length. 57 | """ 58 | previous_tail_byte = self._buffer[-1] if self._buffer else None 59 | self._buffer.extend(chunk) 60 | 61 | # detect TAIL occurence on the boundary of two chunks 62 | if previous_tail_byte is not None and chunk: 63 | last_word = previous_tail_byte, chunk[0] 64 | if last_word == self._TAIL_SEQ: 65 | self._buffered_messages += 1 66 | 67 | self._buffered_messages += chunk.count(self._TAIL) 68 | 69 | def get_message(self) -> bytes: 70 | """ 71 | Return a single message. 72 | 73 | Raise: 74 | ValueError: If message is incomplete. 75 | """ 76 | if not self.is_message_ready: 77 | raise ValueError("No complete message is buffered yet.") 78 | 79 | res = [] 80 | last_byte, byte = None, None 81 | while True: 82 | last_byte, byte = byte, self._buffer.popleft() 83 | res.append(byte) 84 | if last_byte is not None: 85 | last_word = last_byte, byte 86 | if last_word == self._TAIL_SEQ: 87 | self._buffered_messages -= 1 88 | break 89 | return bytes(res) 90 | -------------------------------------------------------------------------------- /skydance/network/session.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import logging 4 | from typing import Tuple 5 | 6 | 7 | log = logging.getLogger(__name__) 8 | 9 | 10 | class Session: 11 | """A session object handling connection re-creation in case of its failure.""" 12 | 13 | def __init__(self, host, port): 14 | self.host = host 15 | self.port = port 16 | self._connection = None 17 | self._write_lock = asyncio.Lock() 18 | self._read_lock = asyncio.Lock() 19 | 20 | async def _get_connection( 21 | self, 22 | ) -> Tuple[asyncio.StreamReader, asyncio.StreamWriter]: 23 | if self._connection is None: 24 | log.debug("Opening connection to: %s:%d", self.host, self.port) 25 | self._connection = await asyncio.open_connection(self.host, self.port) 26 | return self._connection 27 | 28 | async def _close_connection(self) -> None: 29 | if self._connection: 30 | log.debug("Closing connection to: %s:%d", self.host, self.port) 31 | _, writer = self._connection 32 | writer.close() 33 | with contextlib.suppress(ConnectionError, TimeoutError): 34 | await writer.wait_closed() 35 | self._connection = None 36 | 37 | async def write(self, data: bytes): 38 | """ 39 | Write a data to the transport and drain immediatelly. 40 | 41 | This is a wrapper on top of 42 | [`asyncio.streams.StreamWriter.write()`](https://docs.python.org/3/library/asyncio-stream.html#asyncio.StreamWriter.write) 43 | """ 44 | async with self._write_lock: 45 | while True: 46 | try: 47 | _, writer = await self._get_connection() 48 | log.debug("Sending: %s", data.hex(" ")) 49 | writer.write(data) 50 | await writer.drain() 51 | return 52 | except (ConnectionResetError, ConnectionAbortedError): 53 | await self._close_connection() 54 | 55 | async def read(self, n=-1) -> bytes: 56 | """ 57 | Read up to `n` bytes from the transport. 58 | 59 | This is a wrapper on top of 60 | [`asyncio.streams.StreamReader.read()`](https://docs.python.org/3/library/asyncio-stream.html#asyncio.StreamReader.read) 61 | """ 62 | async with self._read_lock: 63 | while True: 64 | try: 65 | reader, _ = await self._get_connection() 66 | res = await reader.read(n) 67 | log.debug("Received: %s", res.hex(" ")) 68 | return res 69 | except (ConnectionResetError, ConnectionAbortedError): 70 | await self._close_connection() 71 | 72 | async def close(self): 73 | """Close connection.""" 74 | await self._close_connection() 75 | 76 | async def __aenter__(self): 77 | """Return auto-closing context manager.""" 78 | await self._get_connection() 79 | return self 80 | 81 | async def __aexit__(self, exc_type, exc_val, exc_tb): 82 | await self.close() 83 | -------------------------------------------------------------------------------- /skydance/network/discovery.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import ipaddress 3 | import logging 4 | from collections import defaultdict 5 | from typing import DefaultDict, Iterable, Mapping, Optional, cast 6 | 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | # type aliases 11 | MacAddress = bytes 12 | DiscoveryResult = Mapping[MacAddress, Iterable[ipaddress.IPv4Address]] 13 | 14 | 15 | class DiscoveryProtocol(asyncio.DatagramProtocol): 16 | """ 17 | Implement discovery protocol used by Skydance Wi-Fi relays. 18 | 19 | Skydance uses HF-LPT130 chip for network communication (Wi-Fi settings, 20 | passing network data to another chips inside, network discovery). 21 | 22 | See Also: 23 | - [HF-LPT130 product page](http://www.hi-flying.com/hf-lpt130) 24 | - [Similar protocol description](https://github.com/zoot1612/plugin_mh/blob/master/MH_Control) 25 | 26 | Example: 27 | >>> protocol = DiscoveryProtocol() 28 | >>> await asyncio.get_event_loop().create_datagram_endpoint( 29 | >>> lambda: protocol, 30 | >>> remote_addr=("192.168.1.255", DiscoveryProtocol.PORT), 31 | >>> allow_broadcast=True, 32 | >>> ) 33 | >>> 34 | >>> for _ in range(3): 35 | >>> protocol.send_discovery_request() 36 | >>> await asyncio.sleep(1) 37 | >>> 38 | >>> for mac, ips in protocol.get_discovery_result(): 39 | >>> print(mac.hex(":"), *ips) 40 | 98:d8:63:a5:9e:5c 192.168.1.5 41 | 98:d8:63:a5:8a:35 192.168.1.8 192.168.1.9 42 | """ 43 | 44 | PORT = 48899 45 | 46 | _DISCOVERY_REQUEST = b"HF-A11ASSISTHREAD" 47 | 48 | _transport: Optional[asyncio.transports.DatagramTransport] = None 49 | _result: DefaultDict[MacAddress, set] 50 | 51 | def __init__(self): 52 | self._result = defaultdict(set) 53 | 54 | # implementation of asyncio.DatagramProtocol follows 55 | 56 | def connection_made(self, transport): 57 | log.debug("Starting discovery protocol") 58 | self._transport = cast(asyncio.transports.DatagramTransport, transport) 59 | 60 | def datagram_received(self, data: bytes, addr): 61 | real_ip_str, _port = addr 62 | real_ip = ipaddress.ip_address(real_ip_str) 63 | log.debug("Discovery reply received from %s: %s", real_ip, data) 64 | _reported_ip_str, mac_str, _model = data.decode("ascii").split(",") 65 | mac = MacAddress.fromhex(mac_str) 66 | self._result[mac].add(real_ip) 67 | 68 | # public API follows 69 | 70 | def send_discovery_request(self): 71 | if not self._transport: 72 | raise ValueError( 73 | "Transport is not available. " 74 | "The protocol must be first initiated using `create_datagram_endpoint`." 75 | ) 76 | log.debug("Sending discovery request") 77 | self._transport.sendto(self._DISCOVERY_REQUEST) 78 | 79 | def get_discovery_result(self) -> DiscoveryResult: 80 | return self._result 81 | 82 | 83 | async def discover_ips_by_mac( 84 | ip: str, *, broadcast: bool = False, retry: int = 3, sleep: float = 1 85 | ) -> DiscoveryResult: 86 | """ 87 | Discover Skydance Wi-Fi relays. 88 | 89 | Args: 90 | ip: IP target of discovery protocol. Can be either individual device IP (to get 91 | its MAC address), or broadcast address (to discover present devices). 92 | broadcast: Whether the IP is broadcast address. On most systems, requires 93 | `sudo` to operate (to bind 0.0.0.0). 94 | retry: How many times to retry sending discovery request. 95 | sleep: Sleep time between subsequent discovery requests. 96 | 97 | Returns: 98 | Mapping of found Skydance Wi-Fi relays. Their MAC address is the key and their 99 | IP addresses are the values (stored in `set`). 100 | """ 101 | protocol = DiscoveryProtocol() 102 | await asyncio.get_event_loop().create_datagram_endpoint( 103 | lambda: protocol, 104 | remote_addr=(ip, DiscoveryProtocol.PORT), 105 | allow_broadcast=broadcast, 106 | ) 107 | for attempt in range(retry): 108 | if attempt: 109 | await asyncio.sleep(sleep) 110 | protocol.send_discovery_request() 111 | 112 | return protocol.get_discovery_result() 113 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Project settings 2 | PROJECT := skydance 3 | PACKAGE := skydance 4 | REPOSITORY := tomasbedrich/skydance 5 | 6 | # Project paths 7 | PACKAGES := $(PACKAGE) tests 8 | CONFIG := $(wildcard *.py) 9 | MODULES := $(wildcard $(PACKAGE)/*.py) 10 | 11 | # Virtual environment paths 12 | VIRTUAL_ENV ?= .venv 13 | 14 | # MAIN TASKS ################################################################## 15 | 16 | .PHONY: all 17 | all: install 18 | 19 | .PHONY: ci 20 | ci: format check test mkdocs ## Run all tasks that determine CI status 21 | 22 | .PHONY: watch 23 | watch: install .clean-test ## Continuously run all CI tasks when files chanage 24 | poetry run sniffer 25 | 26 | # SYSTEM DEPENDENCIES ######################################################### 27 | 28 | .PHONY: doctor 29 | doctor: ## Confirm system dependencies are available 30 | bin/verchew 31 | 32 | # PROJECT DEPENDENCIES ######################################################## 33 | 34 | DEPENDENCIES := $(VIRTUAL_ENV)/.poetry-$(shell bin/checksum pyproject.toml poetry.lock) 35 | 36 | .PHONY: install 37 | install: $(DEPENDENCIES) .cache 38 | 39 | $(DEPENDENCIES): poetry.lock 40 | @ poetry config virtualenvs.in-project true 41 | poetry install 42 | @ touch $@ 43 | 44 | ifndef CI 45 | poetry.lock: pyproject.toml 46 | poetry lock 47 | @ touch $@ 48 | endif 49 | 50 | .cache: 51 | @ mkdir -p .cache 52 | 53 | # CHECKS ###################################################################### 54 | 55 | .PHONY: format 56 | format: install 57 | poetry run isort $(PACKAGES) 58 | poetry run black $(PACKAGES) 59 | @ echo 60 | 61 | .PHONY: check 62 | check: install format ## Run formaters, linters, and static analysis 63 | ifdef CI 64 | git diff --exit-code 65 | endif 66 | poetry run mypy $(PACKAGES) --config-file=.mypy.ini 67 | poetry run pydocstyle $(PACKAGES) $(CONFIG) 68 | 69 | # TESTS ####################################################################### 70 | 71 | RANDOM_SEED ?= $(shell date +%s) 72 | FAILURES := .cache/v/cache/lastfailed 73 | 74 | PYTEST_OPTIONS := --random --random-seed=$(RANDOM_SEED) 75 | ifndef DISABLE_COVERAGE 76 | PYTEST_OPTIONS += --cov=$(PACKAGE) 77 | endif 78 | PYTEST_RERUN_OPTIONS := --last-failed --exitfirst 79 | 80 | .PHONY: test 81 | test: test-all ## Run unit and integration tests 82 | 83 | .PHONY: test-unit 84 | test-unit: install 85 | @ ( mv $(FAILURES) $(FAILURES).bak || true ) > /dev/null 2>&1 86 | poetry run pytest $(PACKAGE) $(PYTEST_OPTIONS) 87 | @ ( mv $(FAILURES).bak $(FAILURES) || true ) > /dev/null 2>&1 88 | poetry run coveragespace $(REPOSITORY) unit 89 | 90 | .PHONY: test-int 91 | test-int: install 92 | @ if test -e $(FAILURES); then poetry run pytest tests $(PYTEST_RERUN_OPTIONS); fi 93 | @ rm -rf $(FAILURES) 94 | poetry run pytest tests $(PYTEST_OPTIONS) 95 | poetry run coveragespace $(REPOSITORY) integration 96 | 97 | .PHONY: test-all 98 | test-all: install 99 | @ if test -e $(FAILURES); then poetry run pytest $(PACKAGES) $(PYTEST_RERUN_OPTIONS); fi 100 | @ rm -rf $(FAILURES) 101 | poetry run pytest $(PACKAGES) $(PYTEST_OPTIONS) 102 | poetry run coveragespace $(REPOSITORY) overall 103 | 104 | .PHONY: read-coverage 105 | read-coverage: 106 | bin/open htmlcov/index.html 107 | 108 | # DOCUMENTATION ############################################################### 109 | 110 | MKDOCS_INDEX := site/index.html 111 | 112 | .PHONY: docs 113 | docs: mkdocs ## Generate documentation 114 | 115 | .PHONY: mkdocs 116 | mkdocs: install $(MKDOCS_INDEX) 117 | $(MKDOCS_INDEX): docs/requirements.txt mkdocs.yml docs/*.md 118 | @ mkdir -p docs/about 119 | @ cd docs && ln -sf ../README.md index.md 120 | @ cd docs/about && ln -sf ../../CHANGELOG.md changelog.md 121 | @ cd docs/about && ln -sf ../../CONTRIBUTING.md contributing.md 122 | @ cd docs/about && ln -sf ../../LICENSE.md license.md 123 | poetry run mkdocs build --clean --strict 124 | 125 | docs/requirements.txt: poetry.lock 126 | @ poetry run pip list --format=freeze | grep mkdocs > $@ 127 | @ poetry run pip list --format=freeze | grep Pygments >> $@ 128 | 129 | .PHONY: mkdocs-serve 130 | mkdocs-serve: mkdocs 131 | eval "sleep 3; bin/open http://127.0.0.1:8000" & 132 | poetry run mkdocs serve 133 | 134 | # BUILD ####################################################################### 135 | 136 | DIST_FILES := dist/*.tar.gz dist/*.whl 137 | 138 | .PHONY: dist 139 | dist: install $(DIST_FILES) 140 | $(DIST_FILES): $(MODULES) pyproject.toml 141 | rm -f $(DIST_FILES) 142 | poetry build 143 | 144 | # RELEASE ##################################################################### 145 | 146 | .PHONY: upload 147 | upload: dist ## Upload the current version to PyPI 148 | git diff --name-only --exit-code 149 | poetry publish 150 | bin/open https://pypi.org/project/$(PROJECT) 151 | 152 | # CLEANUP ##################################################################### 153 | 154 | .PHONY: clean 155 | clean: .clean-build .clean-docs .clean-test .clean-install ## Delete all generated and temporary files 156 | 157 | .PHONY: clean-all 158 | clean-all: clean 159 | rm -rf $(VIRTUAL_ENV) 160 | 161 | .PHONY: .clean-install 162 | .clean-install: 163 | find $(PACKAGES) -name '__pycache__' -delete 164 | rm -rf *.egg-info 165 | 166 | .PHONY: .clean-test 167 | .clean-test: 168 | rm -rf .cache .pytest .coverage htmlcov 169 | 170 | .PHONY: .clean-docs 171 | .clean-docs: 172 | rm -rf docs/*.png site 173 | 174 | .PHONY: .clean-build 175 | .clean-build: 176 | rm -rf *.spec dist build 177 | 178 | # HELP ######################################################################## 179 | 180 | .PHONY: help 181 | help: all 182 | @ grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 183 | 184 | .DEFAULT_GOAL := help 185 | -------------------------------------------------------------------------------- /tests/test_manual.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import pytest 5 | 6 | from skydance.network.buffer import Buffer 7 | from skydance.network.discovery import discover_ips_by_mac 8 | from skydance.network.session import Session 9 | from skydance.protocol import * 10 | 11 | 12 | pytestmark = pytest.mark.skipif( 13 | "CI" in os.environ, 14 | reason="Manual test is supposed to run against a physical Wi-Fi relay " 15 | "attached to the local network.", 16 | ) 17 | 18 | IP = os.getenv("IP", default="192.168.88.113") 19 | IP_BROADCAST = "192.168.3.255" 20 | 21 | log = logging.getLogger(__name__) 22 | 23 | 24 | @pytest.fixture(name="state") 25 | def state_fixture(): 26 | return State() 27 | 28 | 29 | @pytest.mark.asyncio 30 | @pytest.fixture(name="session") 31 | async def session_fixture(): 32 | async with Session(IP, PORT) as session: 33 | yield session 34 | 35 | 36 | @pytest.mark.asyncio 37 | @pytest.mark.parametrize( 38 | "chunk_size", range(1, 41) 39 | ) # GetNumberOfZonesResponse is expected to be 36 bytes long 40 | async def test_buffered_read(state, session, chunk_size): 41 | buffer = Buffer(TAIL) 42 | 43 | log.info("Testing buffered read with chunk_size=%d", chunk_size) 44 | cmd = GetNumberOfZonesCommand(state).raw 45 | await session.write(cmd) 46 | state.increment_frame_number() 47 | 48 | # this is the code under test 49 | while not buffer.is_message_ready: 50 | chunk = await session.read(chunk_size) 51 | buffer.feed(chunk) 52 | res = buffer.get_message() 53 | 54 | number_of_zones = GetNumberOfZonesResponse(res).number 55 | log.info("Got number_of_zones=%d ", number_of_zones) 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_state_frame_number_overflow(state, session): 60 | log.info("Testing State.frame_number overflow") 61 | for _ in range(256): 62 | cmd = GetNumberOfZonesCommand(state).raw 63 | await session.write(cmd) 64 | state.increment_frame_number() 65 | res = await session.read(64) 66 | number_of_zones = GetNumberOfZonesResponse(res).number 67 | log.debug("Got number_of_zones=%d", number_of_zones) 68 | 69 | 70 | @pytest.mark.asyncio 71 | async def test_zone_discovery(state, session): 72 | log.info("Getting number of zones") 73 | cmd = GetNumberOfZonesCommand(state).raw 74 | await session.write(cmd) 75 | state.increment_frame_number() 76 | res = await session.read(64) 77 | 78 | for zone in GetNumberOfZonesResponse(res).zones: 79 | log.info("Getting info about zone=%d", zone) 80 | cmd = GetZoneInfoCommand(state, zone=zone).raw 81 | await session.write(cmd) 82 | state.increment_frame_number() 83 | res = await session.read(64) 84 | zone_info = GetZoneInfoResponse(res) 85 | log.info("Zone=%d has type=%s, name=%s", zone, zone_info.type, zone_info.name) 86 | 87 | 88 | @pytest.mark.asyncio 89 | async def test_ping(state, session): 90 | log.info("Pinging") 91 | cmd = PingCommand(state).raw 92 | await session.write(cmd) 93 | 94 | 95 | @pytest.mark.asyncio 96 | async def test_master_on_off(state, session): 97 | log.info("Master on") 98 | cmd = MasterPowerOnCommand(state).raw 99 | await session.write(cmd) 100 | state.increment_frame_number() 101 | await asyncio.sleep(2) 102 | 103 | log.info("Master off") 104 | cmd = MasterPowerOffCommand(state).raw 105 | await session.write(cmd) 106 | state.increment_frame_number() 107 | await asyncio.sleep(2) 108 | 109 | 110 | @pytest.mark.asyncio 111 | @pytest.mark.parametrize("zone", {1, 2, 3}) 112 | async def test_on_blink_temp_off(state, session, zone: int): 113 | log.info("Powering on") 114 | cmd = PowerOnCommand(state, zone=zone).raw 115 | await session.write(cmd) 116 | state.increment_frame_number() 117 | await asyncio.sleep(2) 118 | 119 | log.info("Starting blink sequence") 120 | cmd = BrightnessCommand(state, zone=zone, brightness=1).raw 121 | await session.write(cmd) 122 | state.increment_frame_number() 123 | await asyncio.sleep(0.5) 124 | cmd = BrightnessCommand(state, zone=zone, brightness=255).raw 125 | await session.write(cmd) 126 | state.increment_frame_number() 127 | await asyncio.sleep(2) 128 | 129 | log.info("Starting white temperature change sequence") 130 | cmd = TemperatureCommand(state, zone=zone, temperature=255).raw 131 | await session.write(cmd) 132 | state.increment_frame_number() 133 | await asyncio.sleep(0.5) 134 | cmd = TemperatureCommand(state, zone=zone, temperature=0).raw 135 | await session.write(cmd) 136 | state.increment_frame_number() 137 | await asyncio.sleep(2) 138 | 139 | log.info("Starting RGBW change sequence") 140 | cmd = RGBWCommand(state, zone=zone, red=255, green=0, blue=0, white=0).raw 141 | await session.write(cmd) 142 | state.increment_frame_number() 143 | await asyncio.sleep(1) 144 | cmd = RGBWCommand(state, zone=zone, red=0, green=255, blue=0, white=0).raw 145 | await session.write(cmd) 146 | state.increment_frame_number() 147 | await asyncio.sleep(1) 148 | cmd = RGBWCommand(state, zone=zone, red=0, green=0, blue=255, white=0).raw 149 | await session.write(cmd) 150 | state.increment_frame_number() 151 | await asyncio.sleep(1) 152 | cmd = RGBWCommand(state, zone=zone, red=0, green=0, blue=0, white=255).raw 153 | await session.write(cmd) 154 | state.increment_frame_number() 155 | await asyncio.sleep(2) 156 | 157 | log.info("Powering off") 158 | cmd = PowerOffCommand(state, zone=zone).raw 159 | await session.write(cmd) 160 | state.increment_frame_number() 161 | # note - zone max+1 means controlling all zones at once 162 | await asyncio.sleep(0.25) 163 | 164 | 165 | @pytest.mark.asyncio 166 | @pytest.mark.parametrize("ip,broadcast", {(IP, False), (IP_BROADCAST, True)}) 167 | async def test_discovery(ip, broadcast): 168 | res = await discover_ips_by_mac(ip, broadcast=broadcast) 169 | for mac, ips in res.items(): 170 | log.info("Discovered MAC: %s, IPs: %s", mac.hex(":"), ",".join(map(str, ips))) 171 | -------------------------------------------------------------------------------- /skydance/tests/test_protocol.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from skydance.protocol import * 4 | 5 | 6 | @pytest.fixture(name="state") 7 | def state_fixture(): 8 | return State() 9 | 10 | 11 | def test_state_frame_number_overflow(): 12 | s = State() 13 | for _ in range(256): 14 | s.increment_frame_number() 15 | assert s.frame_number == bytes([0]) 16 | 17 | 18 | def test_command_bytes(state): 19 | assert PingCommand(state).raw == bytes.fromhex( 20 | "55aa5aa57e00800080e18000000100790000007e" 21 | ) 22 | 23 | 24 | def test_ping(state): 25 | assert PingCommand(state).body == bytes.fromhex("800080e18000000100790000") 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "zone", 30 | [256, -1, 99999999999, "foo", None], 31 | ) 32 | def test_zone_invalid(zone): 33 | with pytest.raises(expected_exception=ValueError): 34 | ZoneCommand.validate_zone(zone) 35 | 36 | 37 | def test_power_on(state): 38 | assert PowerOnCommand(state, zone=2).body == bytes.fromhex( 39 | "800080e180000002000a010001" 40 | ) 41 | 42 | 43 | def test_power_on_higher_zone_number(state): 44 | assert PowerOnCommand(state, zone=15).body == bytes.fromhex( 45 | "800080e180000000400a010001" 46 | ) 47 | 48 | 49 | def test_power_off(state): 50 | assert PowerOffCommand(state, zone=2).body == bytes.fromhex( 51 | "800080e180000002000a010000" 52 | ) 53 | 54 | 55 | def test_master_power_on(state): 56 | assert MasterPowerOnCommand(state).body == bytes.fromhex( 57 | "800080e18000000fff0b0300030001" 58 | ) 59 | 60 | 61 | def test_master_power_off(state): 62 | assert MasterPowerOffCommand(state).body == bytes.fromhex( 63 | "800080e18000000fff0b0300000000" 64 | ) 65 | 66 | 67 | def test_brightness_min(state): 68 | assert BrightnessCommand(state, zone=2, brightness=1).body == bytes.fromhex( 69 | "800080e180000002000702000001" 70 | ) 71 | 72 | 73 | def test_brightness_max(state): 74 | assert BrightnessCommand(state, zone=2, brightness=255).body == bytes.fromhex( 75 | "800080e1800000020007020000ff" 76 | ) 77 | 78 | 79 | @pytest.mark.parametrize( 80 | "brightness", 81 | [0, 256, -1, 99999999999, "foo", None], # level 0 is invalid! 82 | ) 83 | def test_brightness_invalid(brightness): 84 | with pytest.raises(expected_exception=ValueError): 85 | BrightnessCommand.validate_brightness(brightness) 86 | 87 | 88 | def test_temperature_min(state): 89 | assert TemperatureCommand(state, zone=2, temperature=0).body == bytes.fromhex( 90 | "800080e180000002000d02000000" 91 | ) 92 | 93 | 94 | def test_temperature_max(state): 95 | assert TemperatureCommand(state, zone=2, temperature=255).body == bytes.fromhex( 96 | "800080e180000002000d020000ff" 97 | ) 98 | 99 | 100 | @pytest.mark.parametrize( 101 | "temperature", 102 | [256, -1, 99999999999, "foo", None], 103 | ) 104 | def test_temperature_invalid(temperature): 105 | with pytest.raises(expected_exception=ValueError): 106 | TemperatureCommand.validate_temperature(temperature) 107 | 108 | 109 | def test_rgbw_individuals_components(state): 110 | assert RGBWCommand( 111 | state, zone=2, red=255, green=0, blue=0, white=0 112 | ).body == bytes.fromhex("800080e18000000200010700ff000000000000") 113 | assert RGBWCommand( 114 | state, zone=2, red=0, green=255, blue=0, white=0 115 | ).body == bytes.fromhex("800080e1800000020001070000ff0000000000") 116 | assert RGBWCommand( 117 | state, zone=2, red=0, green=0, blue=255, white=0 118 | ).body == bytes.fromhex("800080e180000002000107000000ff00000000") 119 | assert RGBWCommand( 120 | state, zone=2, red=0, green=0, blue=0, white=255 121 | ).body == bytes.fromhex("800080e18000000200010700000000ff000000") 122 | 123 | 124 | def test_rgbw_mixed(state): 125 | assert RGBWCommand( 126 | state, zone=2, red=255, green=128, blue=64, white=1 127 | ).body == bytes.fromhex("800080e18000000200010700ff804001000000") 128 | 129 | 130 | def test_rgbw_all_zero(state): 131 | with pytest.raises(expected_exception=ValueError): 132 | RGBWCommand(state, zone=2, red=0, green=0, blue=0, white=0) 133 | 134 | 135 | def test_rgbw_min(state): 136 | assert RGBWCommand( 137 | state, zone=2, red=0, green=1, blue=0, white=0 138 | ).body == bytes.fromhex("800080e1800000020001070000010000000000") 139 | 140 | 141 | def test_rgbw_max(state): 142 | assert RGBWCommand( 143 | state, zone=2, red=255, green=255, blue=255, white=255 144 | ).body == bytes.fromhex("800080e18000000200010700ffffffff000000") 145 | 146 | 147 | @pytest.mark.parametrize( 148 | "component", 149 | [256, -1, 99999999999, "foo", None], 150 | ) 151 | def test_rgbw_invalid(component): 152 | with pytest.raises(expected_exception=ValueError): 153 | RGBWCommand.validate_component(component, hint="foo") 154 | 155 | 156 | def test_get_number_of_zones(state): 157 | assert GetNumberOfZonesCommand(state).body == bytes.fromhex( 158 | "800080e18000000100790000" 159 | ) 160 | 161 | 162 | number_of_zones_params = { 163 | "55aa5aa57e00800080e18026510100f910008182838485868788898a8b8c8d8e8f90007e": 16, 164 | "55aa5aa57e00800080e18026510100f9100081828384858687880000000000000000007e": 8, 165 | "55aa5aa57e00800080e18026510100f9100000000000000000000000000000000000007e": 0, 166 | } 167 | 168 | 169 | @pytest.mark.parametrize( 170 | "response, num", 171 | [(bytes.fromhex(res), num) for res, num in number_of_zones_params.items()], 172 | ) 173 | def test_get_number_of_zones_response(response, num): 174 | assert GetNumberOfZonesResponse(response).number == num 175 | 176 | 177 | @pytest.mark.parametrize( 178 | "zone", 179 | range(1, 17), 180 | ) 181 | def test_get_zone_name(state, zone: int): 182 | res = GetZoneInfoCommand(state, zone=zone).body 183 | zone_encoded = 2 ** (zone - 1) 184 | expected = bytes().join( 185 | ( 186 | bytes.fromhex("80 00 80 e1 80 00 00"), 187 | struct.pack("")) 252 | success.append(_("~")) 253 | break 254 | else: 255 | if settings.get('optional'): 256 | show(_("?") + " EXPECTED (OPTIONAL): {0}".format(settings['version'])) 257 | success.append(_("?")) 258 | else: 259 | if QUIET: 260 | if "not found" in output: 261 | actual = "Not found" 262 | else: 263 | actual = output.split('\n')[0].strip('.') 264 | expected = settings['version'] or "" 265 | print("{0}: {1}, EXPECTED: {2}".format(name, actual, expected)) 266 | show( 267 | _("x") 268 | + " EXPECTED: {0}".format(settings['version'] or "") 269 | ) 270 | success.append(_("x")) 271 | if settings.get('message'): 272 | show(_("#") + " MESSAGE: {0}".format(settings['message'])) 273 | 274 | show("Results: " + " ".join(success), head=True) 275 | 276 | return _("x") not in success 277 | 278 | 279 | def get_version(program, argument=None): 280 | if argument is None: 281 | args = [program, '--version'] 282 | elif argument: 283 | args = [program, argument] 284 | else: 285 | args = [program] 286 | 287 | show("$ {0}".format(" ".join(args))) 288 | output = call(args) 289 | lines = output.splitlines() 290 | show(lines[0] if lines else "") 291 | 292 | return output 293 | 294 | 295 | def match_version(pattern, output): 296 | if "not found" in output.split('\n')[0]: 297 | return False 298 | 299 | regex = pattern.replace('.', r'\.') + r'(\b|/)' 300 | 301 | log.debug("Matching %s: %s", regex, output) 302 | match = re.match(regex, output) 303 | if match is None: 304 | match = re.match(r'.*[^\d.]' + regex, output) 305 | 306 | return bool(match) 307 | 308 | 309 | def call(args): 310 | try: 311 | process = Popen(args, stdout=PIPE, stderr=STDOUT) 312 | except OSError: 313 | log.debug("Command not found: %s", args[0]) 314 | output = "sh: command not found: {0}".format(args[0]) 315 | else: 316 | raw = process.communicate()[0] 317 | output = raw.decode('utf-8').strip() 318 | log.debug("Command output: %r", output) 319 | 320 | return output 321 | 322 | 323 | def show(text, start='', end='\n', head=False): 324 | """Python 2 and 3 compatible version of print.""" 325 | if QUIET: 326 | return 327 | 328 | if head: 329 | start = '\n' 330 | end = '\n\n' 331 | 332 | if log.getEffectiveLevel() < logging.WARNING: 333 | log.info(text) 334 | else: 335 | formatted = start + text + end 336 | if PY2: 337 | formatted = formatted.encode('utf-8') 338 | sys.stdout.write(formatted) 339 | sys.stdout.flush() 340 | 341 | 342 | def _(word, is_tty=None, supports_utf8=None, supports_ansi=None): 343 | """Format and colorize a word based on available encoding.""" 344 | formatted = word 345 | 346 | if is_tty is None: 347 | is_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() 348 | if supports_utf8 is None: 349 | supports_utf8 = str(sys.stdout.encoding).lower() == 'utf-8' 350 | if supports_ansi is None: 351 | supports_ansi = sys.platform != 'win32' or 'ANSICON' in os.environ 352 | 353 | style_support = supports_utf8 354 | color_support = is_tty and supports_ansi 355 | 356 | if style_support: 357 | formatted = STYLE.get(word, word) 358 | 359 | if color_support and COLOR.get(word): 360 | formatted = COLOR[word] + formatted + COLOR[None] 361 | 362 | return formatted 363 | 364 | 365 | if __name__ == '__main__': # pragma: no cover 366 | main() 367 | -------------------------------------------------------------------------------- /skydance/protocol.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from abc import ABCMeta, abstractmethod 3 | from functools import partial 4 | 5 | from skydance.enum import ZoneType 6 | 7 | 8 | PORT = 8899 9 | """A port used for communication with a relay.""" 10 | 11 | HEAD = bytes.fromhex("55 aa 5a a5 7e") 12 | """A magic byte sequence used as a header for each command and response.""" 13 | 14 | TAIL = bytes.fromhex("00 7e") 15 | """A magic byte sequence marking end of each command and response.""" 16 | 17 | # This is repeated through many commands, don't know why yet. 18 | # It probably has something to do with a relay ID. 19 | # But as reported by other users, it is working with hardcoded ID as well. 20 | # See: https://github.com/tomasbedrich/home-assistant-skydance/issues/1 21 | _COMMAND_MAGIC = bytes.fromhex("80 00 80 e1 80 00 00") 22 | 23 | DEVICE_BASE_TYPE_NORMAL = 0x80 24 | 25 | 26 | class State: 27 | """Holds state of a connection.""" 28 | 29 | _frame_number: int 30 | 31 | def __init__(self): 32 | self._frame_number = 0 33 | 34 | def increment_frame_number(self): 35 | """ 36 | Increment a frame number used by a relay to reconstruct a network stream. 37 | 38 | !!! important 39 | The frame number must be (manually) incremented after each command. 40 | """ 41 | self._frame_number = (self._frame_number + 1) % 256 42 | 43 | @property 44 | def frame_number(self) -> bytes: 45 | return bytes([self._frame_number]) 46 | 47 | 48 | class Command(metaclass=ABCMeta): 49 | """A base command.""" 50 | 51 | def __init__(self, state: State): 52 | """ 53 | Create a Command. 54 | 55 | Args: 56 | state: A state of connection used to generate byte output of a command. 57 | """ 58 | self.state = state 59 | 60 | @property 61 | def raw(self): 62 | """Return complete byte output of a command ready to send over network.""" 63 | return bytes().join((HEAD, self.state.frame_number, self.body, TAIL)) 64 | 65 | @property 66 | @abstractmethod 67 | def body(self) -> bytes: 68 | """ 69 | Return byte body which represents the command core. 70 | 71 | The returned value exclude [HEAD][skydance.protocol.HEAD], 72 | frame number and [TAIL][skydance.protocol.TAIL]. These are added 73 | automatically in [`Command.raw`][skydance.protocol.Command.raw]. 74 | """ 75 | 76 | 77 | class PingCommand(Command): 78 | """Ping a relay to raise a communication error if something is wrong.""" 79 | 80 | body = bytes.fromhex("80 00 80 e1 80 00 00 01 00 79 00 00") 81 | 82 | 83 | class ZoneCommand(Command, metaclass=ABCMeta): 84 | """A base command which controls a specific Zone.""" 85 | 86 | def __init__(self, *args, zone: int, **kwargs): 87 | """ 88 | Create a ZoneCommand. 89 | 90 | Args: 91 | *args: See [Command][skydance.protocol.Command]. 92 | zone: A zone number to control. 93 | **kwargs: See [Command][skydance.protocol.Command]. 94 | """ 95 | super().__init__(*args, **kwargs) 96 | self.validate_zone(zone) 97 | self.zone = zone 98 | 99 | @staticmethod 100 | def validate_zone(zone: int): 101 | """ 102 | Validate a zone number. 103 | 104 | Raise: 105 | ValueError: If zone number is invalid. 106 | """ 107 | try: 108 | if not 0 <= zone <= 255: 109 | raise ValueError("Zone number must fit into one byte.") 110 | except TypeError as e: 111 | raise ValueError("Zone number must be int-like.") from e 112 | 113 | 114 | class PowerCommand(ZoneCommand): 115 | """Power a Zone on/off.""" 116 | 117 | def __init__(self, *args, power: bool, **kwargs): 118 | """ 119 | Create a PowerCommand. 120 | 121 | Args: 122 | *args: See [ZoneCommand][skydance.protocol.ZoneCommand]. 123 | power: A power on/off state. 124 | **kwargs: See [ZoneCommand][skydance.protocol.ZoneCommand]. 125 | """ 126 | super().__init__(*args, **kwargs) 127 | self.power = power 128 | 129 | @property 130 | def body(self) -> bytes: 131 | return bytes().join( 132 | ( 133 | _COMMAND_MAGIC, 134 | # TODO: zone number is probably a 2 byte bitmask of what zones to power on/off 135 | struct.pack(" bytes: 163 | return bytes().join( 164 | ( 165 | _COMMAND_MAGIC, 166 | bytes.fromhex("0F FF 0B 03 00"), 167 | bytes.fromhex("03" if self.power else "00"), 168 | bytes.fromhex("00"), 169 | bytes.fromhex("01" if self.power else "00"), 170 | ) 171 | ) 172 | 173 | 174 | MasterPowerOnCommand = partial(MasterPowerCommand, power=True) 175 | MasterPowerOffCommand = partial(MasterPowerCommand, power=False) 176 | 177 | 178 | class BrightnessCommand(ZoneCommand): 179 | """Change brightness of a Zone.""" 180 | 181 | def __init__(self, *args, brightness: int, **kwargs): 182 | """ 183 | Create a BrightnessCommand. 184 | 185 | Args: 186 | *args: See [ZoneCommand][skydance.protocol.ZoneCommand]. 187 | brightness: A brightness level between 1-255 (higher = more bright). 188 | **kwargs: See [ZoneCommand][skydance.protocol.ZoneCommand]. 189 | """ 190 | super().__init__(*args, **kwargs) 191 | self.validate_brightness(brightness) 192 | self.brightness = brightness 193 | 194 | @staticmethod 195 | def validate_brightness(brightness: int): 196 | """ 197 | Validate a brightness level. 198 | 199 | Raise: 200 | ValueError: If brightness level is invalid. 201 | """ 202 | try: 203 | if not 1 <= brightness <= 255: 204 | raise ValueError("Brightness level must fit into one byte and be >= 1.") 205 | except TypeError as e: 206 | raise ValueError("Brightness level must be int-like.") from e 207 | 208 | @property 209 | def body(self) -> bytes: 210 | return bytes().join( 211 | ( 212 | _COMMAND_MAGIC, 213 | struct.pack(" bytes: 252 | return bytes().join( 253 | ( 254 | _COMMAND_MAGIC, 255 | struct.pack(" bytes: 313 | return bytes().join( 314 | ( 315 | _COMMAND_MAGIC, 316 | struct.pack(" bytes: 332 | return bytes().join( 333 | ( 334 | _COMMAND_MAGIC, 335 | bytes.fromhex("01 00 79 00 00"), 336 | ) 337 | ) 338 | 339 | 340 | class GetZoneInfoCommand(ZoneCommand): 341 | """Discover a zone according to it's number.""" 342 | 343 | @property 344 | def body(self) -> bytes: 345 | return bytes().join( 346 | ( 347 | _COMMAND_MAGIC, 348 | struct.pack(" 0: 407 | self.cmd_data = lbody[li : li + cmd_data_lenght] 408 | li += cmd_data_lenght 409 | 410 | @property 411 | def body(self) -> bytes: 412 | """ 413 | Return byte body which represents the command core. 414 | 415 | The returned value exclude [HEAD][skydance.protocol.HEAD], 416 | frame number and [TAIL][skydance.protocol.TAIL]. 417 | """ 418 | return self.raw[len(HEAD) + 1 : -len(TAIL)] 419 | 420 | 421 | class GetNumberOfZonesResponse(Response): 422 | """ 423 | Parse a response for `GetNumberOfZonesCommand`. 424 | 425 | See: [`GetNumberOfZonesCommand`][skydance.protocol.GetNumberOfZonesCommand]. 426 | """ 427 | 428 | # The packets looks like: 429 | # ... omitted ... 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f 90 00 7e - 16 zones 430 | # ... omitted ... 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f 00 00 7e - 15 zones 431 | # ... omitted ... 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 00 00 00 7e - 14 zones 432 | # ... omitted ... 83 84 85 86 87 88 89 8a 8b 8c 8d 00 00 00 00 7e - 13 zones 433 | 434 | # We can see a clear pattern at the end. 435 | # It is experimentally proven that the pattern doesn't depend on: 436 | # - Zone type (dimmer, CCT, RGB, ...) 437 | # - Zone name 438 | # - Zone status (on/off) 439 | 440 | def __init__(self, raw: bytes): 441 | super().__init__(raw) 442 | self._zones = [] 443 | 444 | for lbyte in self.cmd_data: 445 | if (lbyte & DEVICE_BASE_TYPE_NORMAL) == DEVICE_BASE_TYPE_NORMAL: 446 | zoneId = lbyte & 0x1F 447 | self._zones.append(zoneId) 448 | 449 | @property 450 | def number(self) -> int: 451 | """Return number of zones available.""" 452 | return len(self._zones) 453 | 454 | @property 455 | def zones(self) -> list: 456 | """Return list of IDs of zones available.""" 457 | return self._zones 458 | 459 | 460 | class GetZoneInfoResponse(Response): 461 | """ 462 | Parse a response for `GetZoneInfoCommand`. 463 | 464 | See: [`GetZoneInfoCommand`][skydance.protocol.GetZoneInfoCommand]. 465 | """ 466 | 467 | @property 468 | def type(self) -> ZoneType: 469 | """Return a zone type.""" 470 | return ZoneType(self.cmd_data[0]) 471 | 472 | @property 473 | def name(self) -> str: 474 | """Return a zone name.""" 475 | # Name offset experimentally decoded from response packets. 476 | return self.cmd_data[2:].decode("utf-8", errors="replace").strip(" \x00") 477 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 2.2.1 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 = ["dev"] 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 = "25.1.0" 18 | description = "Classes Without Boilerplate" 19 | optional = false 20 | python-versions = ">=3.8" 21 | groups = ["dev"] 22 | files = [ 23 | {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, 24 | {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, 25 | ] 26 | 27 | [package.extras] 28 | benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 29 | cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 30 | dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 31 | docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 32 | tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] 33 | tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] 34 | 35 | [[package]] 36 | name = "black" 37 | version = "24.10.0" 38 | description = "The uncompromising code formatter." 39 | optional = false 40 | python-versions = ">=3.9" 41 | groups = ["dev"] 42 | files = [ 43 | {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, 44 | {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, 45 | {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, 46 | {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, 47 | {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, 48 | {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, 49 | {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, 50 | {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, 51 | {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, 52 | {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, 53 | {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, 54 | {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, 55 | {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, 56 | {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, 57 | {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, 58 | {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, 59 | {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, 60 | {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, 61 | {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, 62 | {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, 63 | {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, 64 | {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, 65 | ] 66 | 67 | [package.dependencies] 68 | click = ">=8.0.0" 69 | mypy-extensions = ">=0.4.3" 70 | packaging = ">=22.0" 71 | pathspec = ">=0.9.0" 72 | platformdirs = ">=2" 73 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 74 | typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} 75 | 76 | [package.extras] 77 | colorama = ["colorama (>=0.4.3)"] 78 | d = ["aiohttp (>=3.10)"] 79 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 80 | uvloop = ["uvloop (>=0.15.2)"] 81 | 82 | [[package]] 83 | name = "certifi" 84 | version = "2025.1.31" 85 | description = "Python package for providing Mozilla's CA Bundle." 86 | optional = false 87 | python-versions = ">=3.6" 88 | groups = ["dev"] 89 | files = [ 90 | {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, 91 | {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, 92 | ] 93 | 94 | [[package]] 95 | name = "charset-normalizer" 96 | version = "3.4.1" 97 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 98 | optional = false 99 | python-versions = ">=3.7" 100 | groups = ["dev"] 101 | files = [ 102 | {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, 103 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, 104 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, 105 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, 106 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, 107 | {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, 108 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, 109 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, 110 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, 111 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, 112 | {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, 113 | {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, 114 | {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, 115 | {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, 116 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, 117 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, 118 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, 119 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, 120 | {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, 121 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, 122 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, 123 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, 124 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, 125 | {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, 126 | {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, 127 | {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, 128 | {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, 129 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, 130 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, 131 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, 132 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, 133 | {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, 134 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, 135 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, 136 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, 137 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, 138 | {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, 139 | {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, 140 | {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, 141 | {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, 142 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, 143 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, 144 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, 145 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, 146 | {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, 147 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, 148 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, 149 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, 150 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, 151 | {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, 152 | {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, 153 | {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, 154 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, 155 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, 156 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, 157 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, 158 | {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, 159 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, 160 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, 161 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, 162 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, 163 | {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, 164 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, 165 | {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, 166 | {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, 167 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, 168 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, 169 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, 170 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, 171 | {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, 172 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, 173 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, 174 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, 175 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, 176 | {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, 177 | {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, 178 | {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, 179 | {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, 180 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, 181 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, 182 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, 183 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, 184 | {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, 185 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, 186 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, 187 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, 188 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, 189 | {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, 190 | {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, 191 | {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, 192 | {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, 193 | {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, 194 | ] 195 | 196 | [[package]] 197 | name = "click" 198 | version = "8.1.8" 199 | description = "Composable command line interface toolkit" 200 | optional = false 201 | python-versions = ">=3.7" 202 | groups = ["dev"] 203 | files = [ 204 | {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, 205 | {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, 206 | ] 207 | 208 | [package.dependencies] 209 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 210 | 211 | [[package]] 212 | name = "colorama" 213 | version = "0.3.9" 214 | description = "Cross-platform colored terminal text." 215 | optional = false 216 | python-versions = "*" 217 | groups = ["dev"] 218 | files = [ 219 | {file = "colorama-0.3.9-py2.py3-none-any.whl", hash = "sha256:463f8483208e921368c9f306094eb6f725c6ca42b0f97e313cb5d5512459feda"}, 220 | {file = "colorama-0.3.9.tar.gz", hash = "sha256:48eb22f4f8461b1df5734a074b57042430fb06e1d61bd1e11b078c0fe6d7a1f1"}, 221 | ] 222 | 223 | [[package]] 224 | name = "coverage" 225 | version = "7.6.12" 226 | description = "Code coverage measurement for Python" 227 | optional = false 228 | python-versions = ">=3.9" 229 | groups = ["dev"] 230 | files = [ 231 | {file = "coverage-7.6.12-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704c8c8c6ce6569286ae9622e534b4f5b9759b6f2cd643f1c1a61f666d534fe8"}, 232 | {file = "coverage-7.6.12-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ad7525bf0241e5502168ae9c643a2f6c219fa0a283001cee4cf23a9b7da75879"}, 233 | {file = "coverage-7.6.12-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06097c7abfa611c91edb9e6920264e5be1d6ceb374efb4986f38b09eed4cb2fe"}, 234 | {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:220fa6c0ad7d9caef57f2c8771918324563ef0d8272c94974717c3909664e674"}, 235 | {file = "coverage-7.6.12-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3688b99604a24492bcfe1c106278c45586eb819bf66a654d8a9a1433022fb2eb"}, 236 | {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d1a987778b9c71da2fc8948e6f2656da6ef68f59298b7e9786849634c35d2c3c"}, 237 | {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cec6b9ce3bd2b7853d4a4563801292bfee40b030c05a3d29555fd2a8ee9bd68c"}, 238 | {file = "coverage-7.6.12-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ace9048de91293e467b44bce0f0381345078389814ff6e18dbac8fdbf896360e"}, 239 | {file = "coverage-7.6.12-cp310-cp310-win32.whl", hash = "sha256:ea31689f05043d520113e0552f039603c4dd71fa4c287b64cb3606140c66f425"}, 240 | {file = "coverage-7.6.12-cp310-cp310-win_amd64.whl", hash = "sha256:676f92141e3c5492d2a1596d52287d0d963df21bf5e55c8b03075a60e1ddf8aa"}, 241 | {file = "coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015"}, 242 | {file = "coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45"}, 243 | {file = "coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702"}, 244 | {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0"}, 245 | {file = "coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f"}, 246 | {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f"}, 247 | {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d"}, 248 | {file = "coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba"}, 249 | {file = "coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f"}, 250 | {file = "coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558"}, 251 | {file = "coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad"}, 252 | {file = "coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3"}, 253 | {file = "coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574"}, 254 | {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985"}, 255 | {file = "coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750"}, 256 | {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea"}, 257 | {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3"}, 258 | {file = "coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a"}, 259 | {file = "coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95"}, 260 | {file = "coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288"}, 261 | {file = "coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1"}, 262 | {file = "coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd"}, 263 | {file = "coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9"}, 264 | {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e"}, 265 | {file = "coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4"}, 266 | {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6"}, 267 | {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3"}, 268 | {file = "coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc"}, 269 | {file = "coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3"}, 270 | {file = "coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef"}, 271 | {file = "coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e"}, 272 | {file = "coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703"}, 273 | {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0"}, 274 | {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924"}, 275 | {file = "coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b"}, 276 | {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d"}, 277 | {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827"}, 278 | {file = "coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9"}, 279 | {file = "coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3"}, 280 | {file = "coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f"}, 281 | {file = "coverage-7.6.12-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e7575ab65ca8399c8c4f9a7d61bbd2d204c8b8e447aab9d355682205c9dd948d"}, 282 | {file = "coverage-7.6.12-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8161d9fbc7e9fe2326de89cd0abb9f3599bccc1287db0aba285cb68d204ce929"}, 283 | {file = "coverage-7.6.12-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a1e465f398c713f1b212400b4e79a09829cd42aebd360362cd89c5bdc44eb87"}, 284 | {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f25d8b92a4e31ff1bd873654ec367ae811b3a943583e05432ea29264782dc32c"}, 285 | {file = "coverage-7.6.12-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a936309a65cc5ca80fa9f20a442ff9e2d06927ec9a4f54bcba9c14c066323f2"}, 286 | {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aa6f302a3a0b5f240ee201297fff0bbfe2fa0d415a94aeb257d8b461032389bd"}, 287 | {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f973643ef532d4f9be71dd88cf7588936685fdb576d93a79fe9f65bc337d9d73"}, 288 | {file = "coverage-7.6.12-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:78f5243bb6b1060aed6213d5107744c19f9571ec76d54c99cc15938eb69e0e86"}, 289 | {file = "coverage-7.6.12-cp39-cp39-win32.whl", hash = "sha256:69e62c5034291c845fc4df7f8155e8544178b6c774f97a99e2734b05eb5bed31"}, 290 | {file = "coverage-7.6.12-cp39-cp39-win_amd64.whl", hash = "sha256:b01a840ecc25dce235ae4c1b6a0daefb2a203dba0e6e980637ee9c2f6ee0df57"}, 291 | {file = "coverage-7.6.12-pp39.pp310-none-any.whl", hash = "sha256:7e39e845c4d764208e7b8f6a21c541ade741e2c41afabdfa1caa28687a3c98cf"}, 292 | {file = "coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953"}, 293 | {file = "coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2"}, 294 | ] 295 | 296 | [package.dependencies] 297 | tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} 298 | 299 | [package.extras] 300 | toml = ["tomli ; python_full_version <= \"3.11.0a6\""] 301 | 302 | [[package]] 303 | name = "coveragespace" 304 | version = "3.1.1" 305 | description = "A place to track your code coverage metrics." 306 | optional = false 307 | python-versions = ">=3.5,<4.0" 308 | groups = ["dev"] 309 | files = [ 310 | {file = "coveragespace-3.1.1-py3-none-any.whl", hash = "sha256:cc62bf4f2feb419032920270a0c16f1a379b80ac9fc6bd3cefce918bfc90ba27"}, 311 | {file = "coveragespace-3.1.1.tar.gz", hash = "sha256:c5862d04a91bec32fc7dd8487006922e144280dc05e75b26c38f4ffe4e760fb0"}, 312 | ] 313 | 314 | [package.dependencies] 315 | colorama = ">=0.3,<0.4" 316 | coverage = "*" 317 | docopt = ">=0.6,<0.7" 318 | minilog = "*" 319 | requests = ">=2.0,<3.0" 320 | 321 | [[package]] 322 | name = "docopt" 323 | version = "0.6.2" 324 | description = "Pythonic argument parser, that will make you smile" 325 | optional = false 326 | python-versions = "*" 327 | groups = ["dev"] 328 | files = [ 329 | {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, 330 | ] 331 | 332 | [[package]] 333 | name = "ghp-import" 334 | version = "2.1.0" 335 | description = "Copy your docs directly to the gh-pages branch." 336 | optional = false 337 | python-versions = "*" 338 | groups = ["dev"] 339 | files = [ 340 | {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, 341 | {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, 342 | ] 343 | 344 | [package.dependencies] 345 | python-dateutil = ">=2.8.1" 346 | 347 | [package.extras] 348 | dev = ["flake8", "markdown", "twine", "wheel"] 349 | 350 | [[package]] 351 | name = "idna" 352 | version = "3.10" 353 | description = "Internationalized Domain Names in Applications (IDNA)" 354 | optional = false 355 | python-versions = ">=3.6" 356 | groups = ["dev"] 357 | files = [ 358 | {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, 359 | {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, 360 | ] 361 | 362 | [package.extras] 363 | all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] 364 | 365 | [[package]] 366 | name = "importlib-metadata" 367 | version = "8.6.1" 368 | description = "Read metadata from Python packages" 369 | optional = false 370 | python-versions = ">=3.9" 371 | groups = ["dev"] 372 | markers = "python_version == \"3.9\"" 373 | files = [ 374 | {file = "importlib_metadata-8.6.1-py3-none-any.whl", hash = "sha256:02a89390c1e15fdfdc0d7c6b25cb3e62650d0494005c97d6f148bf5b9787525e"}, 375 | {file = "importlib_metadata-8.6.1.tar.gz", hash = "sha256:310b41d755445d74569f993ccfc22838295d9fe005425094fad953d7f15c8580"}, 376 | ] 377 | 378 | [package.dependencies] 379 | zipp = ">=3.20" 380 | 381 | [package.extras] 382 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 383 | cover = ["pytest-cov"] 384 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 385 | enabler = ["pytest-enabler (>=2.2)"] 386 | perf = ["ipython"] 387 | test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] 388 | type = ["pytest-mypy"] 389 | 390 | [[package]] 391 | name = "iniconfig" 392 | version = "2.0.0" 393 | description = "brain-dead simple config-ini parsing" 394 | optional = false 395 | python-versions = ">=3.7" 396 | groups = ["dev"] 397 | files = [ 398 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 399 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 400 | ] 401 | 402 | [[package]] 403 | name = "isort" 404 | version = "5.13.2" 405 | description = "A Python utility / library to sort Python imports." 406 | optional = false 407 | python-versions = ">=3.8.0" 408 | groups = ["dev"] 409 | files = [ 410 | {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, 411 | {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, 412 | ] 413 | 414 | [package.extras] 415 | colors = ["colorama (>=0.4.6)"] 416 | 417 | [[package]] 418 | name = "jinja2" 419 | version = "3.1.6" 420 | description = "A very fast and expressive template engine." 421 | optional = false 422 | python-versions = ">=3.7" 423 | groups = ["dev"] 424 | files = [ 425 | {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, 426 | {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, 427 | ] 428 | 429 | [package.dependencies] 430 | MarkupSafe = ">=2.0" 431 | 432 | [package.extras] 433 | i18n = ["Babel (>=2.7)"] 434 | 435 | [[package]] 436 | name = "macfsevents" 437 | version = "0.8.4" 438 | description = "Thread-based interface to file system observation primitives." 439 | optional = false 440 | python-versions = "*" 441 | groups = ["dev"] 442 | markers = "sys_platform == \"darwin\"" 443 | files = [ 444 | {file = "MacFSEvents-0.8.4.tar.gz", hash = "sha256:bf7283f1d517764ccdc8195b21631dbbac1c506b920bf9a8ea2956b3127651cb"}, 445 | ] 446 | 447 | [[package]] 448 | name = "markdown" 449 | version = "3.3.7" 450 | description = "Python implementation of Markdown." 451 | optional = false 452 | python-versions = ">=3.6" 453 | groups = ["dev"] 454 | files = [ 455 | {file = "Markdown-3.3.7-py3-none-any.whl", hash = "sha256:f5da449a6e1c989a4cea2631aa8ee67caa5a2ef855d551c88f9e309f4634c621"}, 456 | {file = "Markdown-3.3.7.tar.gz", hash = "sha256:cbb516f16218e643d8e0a95b309f77eb118cb138d39a4f27851e6a63581db874"}, 457 | ] 458 | 459 | [package.dependencies] 460 | importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} 461 | 462 | [package.extras] 463 | testing = ["coverage", "pyyaml"] 464 | 465 | [[package]] 466 | name = "markupsafe" 467 | version = "3.0.2" 468 | description = "Safely add untrusted strings to HTML/XML markup." 469 | optional = false 470 | python-versions = ">=3.9" 471 | groups = ["dev"] 472 | files = [ 473 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, 474 | {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, 475 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, 476 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, 477 | {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, 478 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, 479 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, 480 | {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, 481 | {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, 482 | {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, 483 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, 484 | {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, 485 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, 486 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, 487 | {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, 488 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, 489 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, 490 | {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, 491 | {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, 492 | {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, 493 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, 494 | {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, 495 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, 496 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, 497 | {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, 498 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, 499 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, 500 | {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, 501 | {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, 502 | {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, 503 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, 504 | {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, 505 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, 506 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, 507 | {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, 508 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, 509 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, 510 | {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, 511 | {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, 512 | {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, 513 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, 514 | {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, 515 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, 516 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, 517 | {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, 518 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, 519 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, 520 | {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, 521 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, 522 | {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, 523 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, 524 | {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, 525 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, 526 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, 527 | {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, 528 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, 529 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, 530 | {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, 531 | {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, 532 | {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, 533 | {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, 534 | ] 535 | 536 | [[package]] 537 | name = "mergedeep" 538 | version = "1.3.4" 539 | description = "A deep merge function for 🐍." 540 | optional = false 541 | python-versions = ">=3.6" 542 | groups = ["dev"] 543 | files = [ 544 | {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, 545 | {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, 546 | ] 547 | 548 | [[package]] 549 | name = "minilog" 550 | version = "2.3.1" 551 | description = "Minimalistic wrapper for Python logging." 552 | optional = false 553 | python-versions = "<4.0,>=3.8" 554 | groups = ["dev"] 555 | files = [ 556 | {file = "minilog-2.3.1-py3-none-any.whl", hash = "sha256:1a679fefe6140ce1d59c3246adc991f9eb480169e5a6c54d2be9023ee459dc30"}, 557 | {file = "minilog-2.3.1.tar.gz", hash = "sha256:4b602572c3bcdd2d8f00d879f635c0de9e632d5d0307e131c91074be8acf444e"}, 558 | ] 559 | 560 | [[package]] 561 | name = "mkdocs" 562 | version = "1.4.0" 563 | description = "Project documentation with Markdown." 564 | optional = false 565 | python-versions = ">=3.7" 566 | groups = ["dev"] 567 | files = [ 568 | {file = "mkdocs-1.4.0-py3-none-any.whl", hash = "sha256:ce057e9992f017b8e1496b591b6c242cbd34c2d406e2f9af6a19b97dd6248faa"}, 569 | {file = "mkdocs-1.4.0.tar.gz", hash = "sha256:e5549a22d59e7cb230d6a791edd2c3d06690908454c0af82edc31b35d57e3069"}, 570 | ] 571 | 572 | [package.dependencies] 573 | click = ">=7.0" 574 | ghp-import = ">=1.0" 575 | importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} 576 | Jinja2 = ">=2.11.1" 577 | Markdown = ">=3.2.1,<3.4" 578 | mergedeep = ">=1.3.4" 579 | packaging = ">=20.5" 580 | PyYAML = ">=5.1" 581 | pyyaml-env-tag = ">=0.1" 582 | watchdog = ">=2.0" 583 | 584 | [package.extras] 585 | i18n = ["babel (>=2.9.0)"] 586 | 587 | [[package]] 588 | name = "mkdocs-autorefs" 589 | version = "1.4.0" 590 | description = "Automatically link across pages in MkDocs." 591 | optional = false 592 | python-versions = ">=3.9" 593 | groups = ["dev"] 594 | files = [ 595 | {file = "mkdocs_autorefs-1.4.0-py3-none-any.whl", hash = "sha256:bad19f69655878d20194acd0162e29a89c3f7e6365ffe54e72aa3fd1072f240d"}, 596 | {file = "mkdocs_autorefs-1.4.0.tar.gz", hash = "sha256:a9c0aa9c90edbce302c09d050a3c4cb7c76f8b7b2c98f84a7a05f53d00392156"}, 597 | ] 598 | 599 | [package.dependencies] 600 | Markdown = ">=3.3" 601 | markupsafe = ">=2.0.1" 602 | mkdocs = ">=1.1" 603 | 604 | [[package]] 605 | name = "mkdocs-material" 606 | version = "8.5.11" 607 | description = "Documentation that simply works" 608 | optional = false 609 | python-versions = ">=3.7" 610 | groups = ["dev"] 611 | files = [ 612 | {file = "mkdocs_material-8.5.11-py3-none-any.whl", hash = "sha256:c907b4b052240a5778074a30a78f31a1f8ff82d7012356dc26898b97559f082e"}, 613 | {file = "mkdocs_material-8.5.11.tar.gz", hash = "sha256:b0ea0513fd8cab323e8a825d6692ea07fa83e917bb5db042e523afecc7064ab7"}, 614 | ] 615 | 616 | [package.dependencies] 617 | jinja2 = ">=3.0.2" 618 | markdown = ">=3.2" 619 | mkdocs = ">=1.4.0" 620 | mkdocs-material-extensions = ">=1.1" 621 | pygments = ">=2.12" 622 | pymdown-extensions = ">=9.4" 623 | requests = ">=2.26" 624 | 625 | [[package]] 626 | name = "mkdocs-material-extensions" 627 | version = "1.3.1" 628 | description = "Extension pack for Python Markdown and MkDocs Material." 629 | optional = false 630 | python-versions = ">=3.8" 631 | groups = ["dev"] 632 | files = [ 633 | {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, 634 | {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, 635 | ] 636 | 637 | [[package]] 638 | name = "mkdocstrings" 639 | version = "0.25.2" 640 | description = "Automatic documentation from sources, for MkDocs." 641 | optional = false 642 | python-versions = ">=3.8" 643 | groups = ["dev"] 644 | files = [ 645 | {file = "mkdocstrings-0.25.2-py3-none-any.whl", hash = "sha256:9e2cda5e2e12db8bb98d21e3410f3f27f8faab685a24b03b06ba7daa5b92abfc"}, 646 | {file = "mkdocstrings-0.25.2.tar.gz", hash = "sha256:5cf57ad7f61e8be3111a2458b4e49c2029c9cb35525393b179f9c916ca8042dc"}, 647 | ] 648 | 649 | [package.dependencies] 650 | click = ">=7.0" 651 | importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} 652 | Jinja2 = ">=2.11.1" 653 | Markdown = ">=3.3" 654 | MarkupSafe = ">=1.1" 655 | mkdocs = ">=1.4" 656 | mkdocs-autorefs = ">=0.3.1" 657 | platformdirs = ">=2.2.0" 658 | pymdown-extensions = ">=6.3" 659 | typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} 660 | 661 | [package.extras] 662 | crystal = ["mkdocstrings-crystal (>=0.3.4)"] 663 | python = ["mkdocstrings-python (>=0.5.2)"] 664 | python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] 665 | 666 | [[package]] 667 | name = "mypy" 668 | version = "1.15.0" 669 | description = "Optional static typing for Python" 670 | optional = false 671 | python-versions = ">=3.9" 672 | groups = ["dev"] 673 | files = [ 674 | {file = "mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13"}, 675 | {file = "mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559"}, 676 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b"}, 677 | {file = "mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3"}, 678 | {file = "mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b"}, 679 | {file = "mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828"}, 680 | {file = "mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f"}, 681 | {file = "mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5"}, 682 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e"}, 683 | {file = "mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c"}, 684 | {file = "mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f"}, 685 | {file = "mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f"}, 686 | {file = "mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd"}, 687 | {file = "mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f"}, 688 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464"}, 689 | {file = "mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee"}, 690 | {file = "mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e"}, 691 | {file = "mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22"}, 692 | {file = "mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445"}, 693 | {file = "mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d"}, 694 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5"}, 695 | {file = "mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036"}, 696 | {file = "mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357"}, 697 | {file = "mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf"}, 698 | {file = "mypy-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e601a7fa172c2131bff456bb3ee08a88360760d0d2f8cbd7a75a65497e2df078"}, 699 | {file = "mypy-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:712e962a6357634fef20412699a3655c610110e01cdaa6180acec7fc9f8513ba"}, 700 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95579473af29ab73a10bada2f9722856792a36ec5af5399b653aa28360290a5"}, 701 | {file = "mypy-1.15.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f8722560a14cde92fdb1e31597760dc35f9f5524cce17836c0d22841830fd5b"}, 702 | {file = "mypy-1.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fbb8da62dc352133d7d7ca90ed2fb0e9d42bb1a32724c287d3c76c58cbaa9c2"}, 703 | {file = "mypy-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:d10d994b41fb3497719bbf866f227b3489048ea4bbbb5015357db306249f7980"}, 704 | {file = "mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e"}, 705 | {file = "mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43"}, 706 | ] 707 | 708 | [package.dependencies] 709 | mypy_extensions = ">=1.0.0" 710 | tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} 711 | typing_extensions = ">=4.6.0" 712 | 713 | [package.extras] 714 | dmypy = ["psutil (>=4.0)"] 715 | faster-cache = ["orjson"] 716 | install-types = ["pip"] 717 | mypyc = ["setuptools (>=50)"] 718 | reports = ["lxml"] 719 | 720 | [[package]] 721 | name = "mypy-extensions" 722 | version = "1.0.0" 723 | description = "Type system extensions for programs checked with the mypy type checker." 724 | optional = false 725 | python-versions = ">=3.5" 726 | groups = ["dev"] 727 | files = [ 728 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 729 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 730 | ] 731 | 732 | [[package]] 733 | name = "nose" 734 | version = "1.3.7" 735 | description = "nose extends unittest to make testing easier" 736 | optional = false 737 | python-versions = "*" 738 | groups = ["dev"] 739 | files = [ 740 | {file = "nose-1.3.7-py2-none-any.whl", hash = "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a"}, 741 | {file = "nose-1.3.7-py3-none-any.whl", hash = "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac"}, 742 | {file = "nose-1.3.7.tar.gz", hash = "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98"}, 743 | ] 744 | 745 | [[package]] 746 | name = "packaging" 747 | version = "24.2" 748 | description = "Core utilities for Python packages" 749 | optional = false 750 | python-versions = ">=3.8" 751 | groups = ["dev"] 752 | files = [ 753 | {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, 754 | {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, 755 | ] 756 | 757 | [[package]] 758 | name = "pathspec" 759 | version = "0.12.1" 760 | description = "Utility library for gitignore style pattern matching of file paths." 761 | optional = false 762 | python-versions = ">=3.8" 763 | groups = ["dev"] 764 | files = [ 765 | {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, 766 | {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, 767 | ] 768 | 769 | [[package]] 770 | name = "platformdirs" 771 | version = "4.3.6" 772 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." 773 | optional = false 774 | python-versions = ">=3.8" 775 | groups = ["dev"] 776 | files = [ 777 | {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, 778 | {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, 779 | ] 780 | 781 | [package.extras] 782 | docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] 783 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] 784 | type = ["mypy (>=1.11.2)"] 785 | 786 | [[package]] 787 | name = "pluggy" 788 | version = "1.5.0" 789 | description = "plugin and hook calling mechanisms for python" 790 | optional = false 791 | python-versions = ">=3.8" 792 | groups = ["dev"] 793 | files = [ 794 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 795 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 796 | ] 797 | 798 | [package.extras] 799 | dev = ["pre-commit", "tox"] 800 | testing = ["pytest", "pytest-benchmark"] 801 | 802 | [[package]] 803 | name = "py" 804 | version = "1.11.0" 805 | description = "library with cross-python path, ini-parsing, io, code, log facilities" 806 | optional = false 807 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 808 | groups = ["dev"] 809 | files = [ 810 | {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, 811 | {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, 812 | ] 813 | 814 | [[package]] 815 | name = "pydocstyle" 816 | version = "6.3.0" 817 | description = "Python docstring style checker" 818 | optional = false 819 | python-versions = ">=3.6" 820 | groups = ["dev"] 821 | files = [ 822 | {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, 823 | {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, 824 | ] 825 | 826 | [package.dependencies] 827 | snowballstemmer = ">=2.2.0" 828 | 829 | [package.extras] 830 | toml = ["tomli (>=1.2.3) ; python_version < \"3.11\""] 831 | 832 | [[package]] 833 | name = "pygments" 834 | version = "2.19.1" 835 | description = "Pygments is a syntax highlighting package written in Python." 836 | optional = false 837 | python-versions = ">=3.8" 838 | groups = ["dev"] 839 | files = [ 840 | {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, 841 | {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, 842 | ] 843 | 844 | [package.extras] 845 | windows-terminal = ["colorama (>=0.4.6)"] 846 | 847 | [[package]] 848 | name = "pymdown-extensions" 849 | version = "10.4" 850 | description = "Extension pack for Python Markdown." 851 | optional = false 852 | python-versions = ">=3.8" 853 | groups = ["dev"] 854 | files = [ 855 | {file = "pymdown_extensions-10.4-py3-none-any.whl", hash = "sha256:cfc28d6a09d19448bcbf8eee3ce098c7d17ff99f7bd3069db4819af181212037"}, 856 | {file = "pymdown_extensions-10.4.tar.gz", hash = "sha256:bc46f11749ecd4d6b71cf62396104b4a200bad3498cb0f5dad1b8502fe461a35"}, 857 | ] 858 | 859 | [package.dependencies] 860 | markdown = ">=3.2" 861 | pyyaml = "*" 862 | 863 | [package.extras] 864 | extra = ["pygments (>=2.12)"] 865 | 866 | [[package]] 867 | name = "pync" 868 | version = "2.0.3" 869 | description = "Python Wrapper for Mac OS 10.10 Notification Center" 870 | optional = false 871 | python-versions = "*" 872 | groups = ["dev"] 873 | markers = "sys_platform == \"darwin\"" 874 | files = [ 875 | {file = "pync-2.0.3.tar.gz", hash = "sha256:38b9e61735a3161f9211a5773c5f5ea698f36af4ff7f77fa03e8d1ff0caa117f"}, 876 | ] 877 | 878 | [package.dependencies] 879 | python-dateutil = ">=2.0" 880 | 881 | [[package]] 882 | name = "pytest" 883 | version = "6.2.5" 884 | description = "pytest: simple powerful testing with Python" 885 | optional = false 886 | python-versions = ">=3.6" 887 | groups = ["dev"] 888 | files = [ 889 | {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, 890 | {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, 891 | ] 892 | 893 | [package.dependencies] 894 | atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} 895 | attrs = ">=19.2.0" 896 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 897 | iniconfig = "*" 898 | packaging = "*" 899 | pluggy = ">=0.12,<2.0" 900 | py = ">=1.8.2" 901 | toml = "*" 902 | 903 | [package.extras] 904 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] 905 | 906 | [[package]] 907 | name = "pytest-asyncio" 908 | version = "0.14.0" 909 | description = "Pytest support for asyncio." 910 | optional = false 911 | python-versions = ">= 3.5" 912 | groups = ["dev"] 913 | files = [ 914 | {file = "pytest-asyncio-0.14.0.tar.gz", hash = "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"}, 915 | {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"}, 916 | ] 917 | 918 | [package.dependencies] 919 | pytest = ">=5.4.0" 920 | 921 | [package.extras] 922 | testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] 923 | 924 | [[package]] 925 | name = "pytest-cov" 926 | version = "6.0.0" 927 | description = "Pytest plugin for measuring coverage." 928 | optional = false 929 | python-versions = ">=3.9" 930 | groups = ["dev"] 931 | files = [ 932 | {file = "pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0"}, 933 | {file = "pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35"}, 934 | ] 935 | 936 | [package.dependencies] 937 | coverage = {version = ">=7.5", extras = ["toml"]} 938 | pytest = ">=4.6" 939 | 940 | [package.extras] 941 | testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] 942 | 943 | [[package]] 944 | name = "pytest-describe" 945 | version = "0.12.0" 946 | description = "Describe-style plugin for pytest" 947 | optional = false 948 | python-versions = "*" 949 | groups = ["dev"] 950 | files = [] 951 | develop = false 952 | 953 | [package.dependencies] 954 | pytest = ">=2.6.0" 955 | 956 | [package.source] 957 | type = "git" 958 | url = "https://github.com/pytest-dev/pytest-describe" 959 | reference = "453aa9045b265e313f356f1492d8991c02a6aea6" 960 | resolved_reference = "453aa9045b265e313f356f1492d8991c02a6aea6" 961 | 962 | [[package]] 963 | name = "pytest-expecter" 964 | version = "2.3" 965 | description = "Better testing with expecter and pytest." 966 | optional = false 967 | python-versions = ">=3.6,<4.0" 968 | groups = ["dev"] 969 | files = [ 970 | {file = "pytest-expecter-2.3.tar.gz", hash = "sha256:8e6a3e565fbc524e5a4988b664d1b748e0a810b33233880aa5d3d78970351e06"}, 971 | {file = "pytest_expecter-2.3-py3-none-any.whl", hash = "sha256:6336d7f43221500e392014a949fd77ee2c2a84bcae2be7669acdd0d7c89b89e9"}, 972 | ] 973 | 974 | [[package]] 975 | name = "pytest-random" 976 | version = "0.02" 977 | description = "py.test plugin to randomize tests" 978 | optional = false 979 | python-versions = "*" 980 | groups = ["dev"] 981 | files = [ 982 | {file = "pytest-random-0.02.tar.gz", hash = "sha256:92f25db8c5d9ffc20d90b51997b914372d6955cb9cf1f6ead45b90514fc0eddd"}, 983 | ] 984 | 985 | [package.dependencies] 986 | pytest = ">=2.2.3" 987 | 988 | [[package]] 989 | name = "python-dateutil" 990 | version = "2.9.0.post0" 991 | description = "Extensions to the standard Python datetime module" 992 | optional = false 993 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 994 | groups = ["dev"] 995 | files = [ 996 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 997 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 998 | ] 999 | 1000 | [package.dependencies] 1001 | six = ">=1.5" 1002 | 1003 | [[package]] 1004 | name = "python-termstyle" 1005 | version = "0.1.10" 1006 | description = "console colouring for python" 1007 | optional = false 1008 | python-versions = "*" 1009 | groups = ["dev"] 1010 | files = [ 1011 | {file = "python-termstyle-0.1.10.tar.gz", hash = "sha256:f42a6bb16fbfc5e2c66d553e7ad46524ea833872f75ee5d827c15115fafc94e2"}, 1012 | {file = "python-termstyle-0.1.10.tgz", hash = "sha256:6faf42ba42f2826c38cf70dacb3ac51f248a418e48afc0e36593df11cf3ab1d2"}, 1013 | ] 1014 | 1015 | [package.dependencies] 1016 | setuptools = "*" 1017 | 1018 | [[package]] 1019 | name = "pyyaml" 1020 | version = "6.0.2" 1021 | description = "YAML parser and emitter for Python" 1022 | optional = false 1023 | python-versions = ">=3.8" 1024 | groups = ["dev"] 1025 | files = [ 1026 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, 1027 | {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, 1028 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, 1029 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, 1030 | {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, 1031 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, 1032 | {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, 1033 | {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, 1034 | {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, 1035 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, 1036 | {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, 1037 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, 1038 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, 1039 | {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, 1040 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, 1041 | {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, 1042 | {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, 1043 | {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, 1044 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, 1045 | {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, 1046 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, 1047 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, 1048 | {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, 1049 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, 1050 | {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, 1051 | {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, 1052 | {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, 1053 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, 1054 | {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, 1055 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, 1056 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, 1057 | {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, 1058 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, 1059 | {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, 1060 | {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, 1061 | {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, 1062 | {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, 1063 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, 1064 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, 1065 | {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, 1066 | {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, 1067 | {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, 1068 | {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, 1069 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, 1070 | {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, 1071 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, 1072 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, 1073 | {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, 1074 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, 1075 | {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, 1076 | {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, 1077 | {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, 1078 | {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, 1079 | ] 1080 | 1081 | [[package]] 1082 | name = "pyyaml-env-tag" 1083 | version = "0.1" 1084 | description = "A custom YAML tag for referencing environment variables in YAML files. " 1085 | optional = false 1086 | python-versions = ">=3.6" 1087 | groups = ["dev"] 1088 | files = [ 1089 | {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, 1090 | {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, 1091 | ] 1092 | 1093 | [package.dependencies] 1094 | pyyaml = "*" 1095 | 1096 | [[package]] 1097 | name = "requests" 1098 | version = "2.32.4" 1099 | description = "Python HTTP for Humans." 1100 | optional = false 1101 | python-versions = ">=3.8" 1102 | groups = ["dev"] 1103 | files = [ 1104 | {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, 1105 | {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, 1106 | ] 1107 | 1108 | [package.dependencies] 1109 | certifi = ">=2017.4.17" 1110 | charset_normalizer = ">=2,<4" 1111 | idna = ">=2.5,<4" 1112 | urllib3 = ">=1.21.1,<3" 1113 | 1114 | [package.extras] 1115 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 1116 | use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] 1117 | 1118 | [[package]] 1119 | name = "setuptools" 1120 | version = "78.1.1" 1121 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 1122 | optional = false 1123 | python-versions = ">=3.9" 1124 | groups = ["dev"] 1125 | files = [ 1126 | {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, 1127 | {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, 1128 | ] 1129 | 1130 | [package.extras] 1131 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] 1132 | core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] 1133 | cover = ["pytest-cov"] 1134 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] 1135 | enabler = ["pytest-enabler (>=2.2)"] 1136 | test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] 1137 | type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] 1138 | 1139 | [[package]] 1140 | name = "six" 1141 | version = "1.17.0" 1142 | description = "Python 2 and 3 compatibility utilities" 1143 | optional = false 1144 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 1145 | groups = ["dev"] 1146 | files = [ 1147 | {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, 1148 | {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, 1149 | ] 1150 | 1151 | [[package]] 1152 | name = "sniffer" 1153 | version = "0.4.1" 1154 | description = "An automatic test runner. Supports nose out of the box." 1155 | optional = false 1156 | python-versions = "*" 1157 | groups = ["dev"] 1158 | files = [ 1159 | {file = "sniffer-0.4.1-py2.py3-none-any.whl", hash = "sha256:f120843fe152d0e380402fc11313b151e2044c47fdd36895de2efedc8624dbb8"}, 1160 | {file = "sniffer-0.4.1.tar.gz", hash = "sha256:b37665053fb83d7790bf9e51d616c11970863d14b5ea5a51155a4e95759d1529"}, 1161 | ] 1162 | 1163 | [package.dependencies] 1164 | colorama = "*" 1165 | nose = "*" 1166 | python-termstyle = "*" 1167 | 1168 | [package.extras] 1169 | growl = ["gntp (==0.7)"] 1170 | libnotify = ["py-notify (==0.3.1)"] 1171 | linux = ["pyinotify (==0.9.0)"] 1172 | osx = ["MacFSEvents (==0.2.8)"] 1173 | 1174 | [[package]] 1175 | name = "snowballstemmer" 1176 | version = "2.2.0" 1177 | description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." 1178 | optional = false 1179 | python-versions = "*" 1180 | groups = ["dev"] 1181 | files = [ 1182 | {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, 1183 | {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, 1184 | ] 1185 | 1186 | [[package]] 1187 | name = "toml" 1188 | version = "0.10.2" 1189 | description = "Python Library for Tom's Obvious, Minimal Language" 1190 | optional = false 1191 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 1192 | groups = ["dev"] 1193 | files = [ 1194 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 1195 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 1196 | ] 1197 | 1198 | [[package]] 1199 | name = "tomli" 1200 | version = "2.2.1" 1201 | description = "A lil' TOML parser" 1202 | optional = false 1203 | python-versions = ">=3.8" 1204 | groups = ["dev"] 1205 | markers = "python_full_version <= \"3.11.0a6\"" 1206 | files = [ 1207 | {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, 1208 | {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, 1209 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, 1210 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, 1211 | {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, 1212 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, 1213 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, 1214 | {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, 1215 | {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, 1216 | {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, 1217 | {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, 1218 | {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, 1219 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, 1220 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, 1221 | {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, 1222 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, 1223 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, 1224 | {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, 1225 | {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, 1226 | {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, 1227 | {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, 1228 | {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, 1229 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, 1230 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, 1231 | {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, 1232 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, 1233 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, 1234 | {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, 1235 | {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, 1236 | {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, 1237 | {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, 1238 | {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, 1239 | ] 1240 | 1241 | [[package]] 1242 | name = "typing-extensions" 1243 | version = "4.12.2" 1244 | description = "Backported and Experimental Type Hints for Python 3.8+" 1245 | optional = false 1246 | python-versions = ">=3.8" 1247 | groups = ["dev"] 1248 | files = [ 1249 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 1250 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 1251 | ] 1252 | 1253 | [[package]] 1254 | name = "urllib3" 1255 | version = "2.6.0" 1256 | description = "HTTP library with thread-safe connection pooling, file post, and more." 1257 | optional = false 1258 | python-versions = ">=3.9" 1259 | groups = ["dev"] 1260 | files = [ 1261 | {file = "urllib3-2.6.0-py3-none-any.whl", hash = "sha256:c90f7a39f716c572c4e3e58509581ebd83f9b59cced005b7db7ad2d22b0db99f"}, 1262 | {file = "urllib3-2.6.0.tar.gz", hash = "sha256:cb9bcef5a4b345d5da5d145dc3e30834f58e8018828cbc724d30b4cb7d4d49f1"}, 1263 | ] 1264 | 1265 | [package.extras] 1266 | brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] 1267 | h2 = ["h2 (>=4,<5)"] 1268 | socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] 1269 | zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] 1270 | 1271 | [[package]] 1272 | name = "watchdog" 1273 | version = "6.0.0" 1274 | description = "Filesystem events monitoring" 1275 | optional = false 1276 | python-versions = ">=3.9" 1277 | groups = ["dev"] 1278 | files = [ 1279 | {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, 1280 | {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, 1281 | {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, 1282 | {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, 1283 | {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, 1284 | {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, 1285 | {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, 1286 | {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, 1287 | {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, 1288 | {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, 1289 | {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, 1290 | {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, 1291 | {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, 1292 | {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, 1293 | {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, 1294 | {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, 1295 | {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, 1296 | {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, 1297 | {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, 1298 | {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, 1299 | {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, 1300 | {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, 1301 | {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, 1302 | {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, 1303 | {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, 1304 | {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, 1305 | {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, 1306 | {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, 1307 | {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, 1308 | {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, 1309 | ] 1310 | 1311 | [package.extras] 1312 | watchmedo = ["PyYAML (>=3.10)"] 1313 | 1314 | [[package]] 1315 | name = "zipp" 1316 | version = "3.21.0" 1317 | description = "Backport of pathlib-compatible object wrapper for zip files" 1318 | optional = false 1319 | python-versions = ">=3.9" 1320 | groups = ["dev"] 1321 | markers = "python_version == \"3.9\"" 1322 | files = [ 1323 | {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, 1324 | {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, 1325 | ] 1326 | 1327 | [package.extras] 1328 | check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] 1329 | cover = ["pytest-cov"] 1330 | doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] 1331 | enabler = ["pytest-enabler (>=2.2)"] 1332 | test = ["big-O", "importlib-resources ; python_version < \"3.9\"", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] 1333 | type = ["pytest-mypy"] 1334 | 1335 | [metadata] 1336 | lock-version = "2.1" 1337 | python-versions = "^3.9" 1338 | content-hash = "69f396e5d71e7ed9a9e97cb64157797afc4248045eb3677b37db28b90aee9b63" 1339 | --------------------------------------------------------------------------------