├── 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 |
6 |
7 | [](https://pypi.org/project/skydance)
8 | [](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 |
--------------------------------------------------------------------------------